diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PoMsgctxtTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PoMsgctxtTestData.kt new file mode 100644 index 00000000000..e19c3139dea --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PoMsgctxtTestData.kt @@ -0,0 +1,19 @@ +package io.tolgee.development.testDataBuilder.data + +class PoMsgctxtTestData : BaseTestData() { + init { + projectBuilder.apply { + addKey { + name = "plain.key" + }.build { + addTranslation("en", "Plain key without msgctxt") + } + + addKey { + name = "menu\u0004Open" + }.build { + addTranslation("en", "Open file from the menu") + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/contsants.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/contsants.kt index 7318a6a6848..895775c96c3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/po/contsants.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/contsants.kt @@ -1,3 +1,5 @@ package io.tolgee.formats.po const val PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY = "_poFileMsgIdPlural" + +const val PO_MSGCTXT_KEY_SEPARATOR = "\u0004" diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoFileProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoFileProcessor.kt index 98c54338d18..e062a7838af 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoFileProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoFileProcessor.kt @@ -6,6 +6,7 @@ import io.tolgee.formats.ImportFileProcessor import io.tolgee.formats.MessageConvertorResult import io.tolgee.formats.importCommon.ImportFormat import io.tolgee.formats.po.PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY +import io.tolgee.formats.po.PO_MSGCTXT_KEY_SEPARATOR import io.tolgee.formats.po.`in`.data.PoParsedTranslation import io.tolgee.formats.po.`in`.data.PoParserResult import io.tolgee.model.dataImport.ImportLanguage @@ -26,7 +27,7 @@ class PoFileProcessor( context.languages[languageId] = ImportLanguage(languageId, context.fileEntity) parsed.translations.forEachIndexed { idx, poTranslation -> - val keyName = poTranslation.msgid.toString() + val keyName = buildKeyName(poTranslation) if (poTranslation.msgidPlural.isNotEmpty()) { addPlural(poTranslation, idx) @@ -72,7 +73,7 @@ class PoFileProcessor( val plurals = poTranslation.msgstrPlurals?.map { it.key to it.value.toString() }?.toMap() plurals?.let { val (converted, convertedBy) = getConvertedMessage(poTranslation, plurals) - val keyName = poTranslation.msgid.toString() + val keyName = buildKeyName(poTranslation) poTranslation.msgidPlural.toString().nullIfEmpty?.let { context.setCustom(keyName, PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY, it) } @@ -88,6 +89,15 @@ class PoFileProcessor( } } + private fun buildKeyName(poTranslation: PoParsedTranslation): String { + val msgid = poTranslation.msgid.toString() + val msgctxt = poTranslation.msgctxt.toString() + if (msgctxt.isEmpty()) { + return msgid + } + return "$msgctxt$PO_MSGCTXT_KEY_SEPARATOR$msgid" + } + private fun getConvertedMessage( poTranslation: PoParsedTranslation, stringOrPluralForms: Any?, diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoParser.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoParser.kt index 95597a9af5e..75f45551b7f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoParser.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/PoParser.kt @@ -4,14 +4,13 @@ import io.tolgee.exceptions.PoParserException import io.tolgee.formats.po.`in`.data.PoParsedTranslation import io.tolgee.formats.po.`in`.data.PoParserMeta import io.tolgee.formats.po.`in`.data.PoParserResult -import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType -import io.tolgee.model.dataImport.issues.paramTypes.FileIssueParamType import io.tolgee.service.dataImport.processors.FileProcessorContext import java.util.Locale class PoParser( private val context: FileProcessorContext, ) { + private var expectMsgCtxt = false private var expectMsgId = false private var expectMsgStr = false private var expectMsgIdPlural = false @@ -50,7 +49,7 @@ class PoParser( private fun processHeader(): PoParserMeta { val result = PoParserMeta() - translations.filter { it.msgid.toString() == "" }.forEach { + translations.filter { it.msgid.toString() == "" && it.msgctxt.toString() == "" }.forEach { it.msgstr.split("\n").map { metaLine -> val trimmed = metaLine.trim() if (trimmed.isBlank()) { @@ -253,10 +252,8 @@ class PoParser( } isKeyword("msgctxt") -> { - context.fileEntity.addIssue( - FileIssueType.PO_MSGCTXT_NOT_SUPPORTED, - mapOf(FileIssueParamType.LINE to currentLine.toString()), - ) + possibleEndTranslationBefore() + expectMsgCtxt = true } current.matches("^msgstr\\[\\d+]$".toRegex()) -> { @@ -294,6 +291,10 @@ class PoParser( private fun storeCurrent() { createdTranslation.apply { + if (expectMsgCtxt) { + this.msgctxt.append(currentSequence) + } + if (expectMsgId) { this.msgid.append(currentSequence) } @@ -322,6 +323,7 @@ class PoParser( private fun resetValueExpectations() { expectValue = false + expectMsgCtxt = false expectMsgId = false expectMsgStr = false expectMsgIdPlural = false diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParsedTranslation.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParsedTranslation.kt index cf4bc44c64b..5711df3cc4b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParsedTranslation.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/in/data/PoParsedTranslation.kt @@ -1,6 +1,7 @@ package io.tolgee.formats.po.`in`.data class PoParsedTranslation { + var msgctxt: StringBuilder = StringBuilder() var msgid: StringBuilder = StringBuilder() var msgidPlural: StringBuilder = StringBuilder() var msgstr: StringBuilder = StringBuilder() diff --git a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/PoFileExporter.kt b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/PoFileExporter.kt index ea73ef4d161..2a5a9538710 100644 --- a/backend/data/src/main/kotlin/io/tolgee/formats/po/out/PoFileExporter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/formats/po/out/PoFileExporter.kt @@ -4,11 +4,13 @@ import io.tolgee.dtos.IExportParams import io.tolgee.formats.ExportMessageFormat import io.tolgee.formats.getPluralData import io.tolgee.formats.po.PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY +import io.tolgee.formats.po.PO_MSGCTXT_KEY_SEPARATOR import io.tolgee.model.ILanguage 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.text.split class PoFileExporter( val translations: List, @@ -33,7 +35,8 @@ class PoFileExporter( val resultBuilder = getResultStringBuilder(translation) val converted = convertMessage(translation) resultBuilder.appendLine() - resultBuilder.writeMsgId(translation.key.name) + resultBuilder.writeMsgCtxt(translation.key.name.getMsgCtxt()) + resultBuilder.writeMsgId(translation.key.name.getMsgId()) resultBuilder.writeMsgIdPlural(translation, converted) resultBuilder.writeMsgStr(converted) } @@ -49,6 +52,12 @@ class PoFileExporter( ).convert() } + private fun StringBuilder.writeMsgCtxt(msgctxt: String?) { + if (!msgctxt.isNullOrEmpty()) { + this.append(convertToPoMultilineString("msgctxt", msgctxt)) + } + } + private fun StringBuilder.writeMsgId(keyName: String) { this.append(convertToPoMultilineString("msgid", keyName)) } @@ -101,7 +110,8 @@ class PoFileExporter( translation: ExportTranslationView, converted: ToPoConversionResult, ) { - val msgIdPlural = translation.key.custom?.get(PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY) as? String ?: translation.key.name + val msgIdPlural = + translation.key.custom?.get(PO_FILE_MSG_ID_PLURAL_CUSTOM_KEY) as? String ?: translation.key.name.getMsgId() if (converted.isPlural()) { this.append( convertToPoMultilineString( @@ -111,4 +121,14 @@ class PoFileExporter( ) } } + + private fun String.getMsgId(): String { + val parts = split(PO_MSGCTXT_KEY_SEPARATOR, limit = 2) + return parts.last() + } + + private fun String.getMsgCtxt(): String? { + val parts = split(PO_MSGCTXT_KEY_SEPARATOR, limit = 2) + return parts.takeIf { parts.size > 1 }?.first() + } } diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoFileProcessorTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoFileProcessorTest.kt index d0a587d2586..fa2b743c469 100644 --- a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoFileProcessorTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoFileProcessorTest.kt @@ -179,6 +179,44 @@ class PoFileProcessorTest { } } + @Test + fun `joins msgctxt and msgid into keyName via separator`() { + mockImportFile("example_msgctxt.po") + PoFileProcessor(mockUtil.fileProcessorContext).process() + val keys = mockUtil.fileProcessorContext.translations.keys + assertThat(keys).contains( + "menu\u0004Open", + "verb\u0004Open", + "Open", + "items\u0004%d item", + ) + } + + @Test + fun `drops msgctxt-only entry whose msgid is empty`() { + mockImportFile("example_msgctxt_only.po") + PoFileProcessor(mockUtil.fileProcessorContext).process() + assertThat(mockUtil.fileProcessorContext.translations.keys) + .containsExactly("menu\u0004Open") + } + + @Test + fun `treats empty msgctxt as no msgctxt`() { + mockImportFile("example_msgctxt.po") + PoFileProcessor(mockUtil.fileProcessorContext).process() + val keys = mockUtil.fileProcessorContext.translations.keys + assertThat(keys).contains("Cancel") + assertThat(keys).doesNotContain("\u0004Cancel") + } + + @Test + fun `preserves msgctxt escapes in keyName`() { + mockImportFile("example_msgctxt_escapes.po") + PoFileProcessor(mockUtil.fileProcessorContext).process() + val expected = "a \"quoted\" ctx\nwith newline\u0004Save" + assertThat(mockUtil.fileProcessorContext.translations.keys).contains(expected) + } + @Test fun `respects provided format`() { mockUtil.mockIt("en.json", "src/test/resources/import/po/example.po") diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoParserTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoParserTest.kt index 7f5c73e2ec5..0b021aec2fc 100644 --- a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoParserTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/in/PoParserTest.kt @@ -1,6 +1,7 @@ package io.tolgee.unit.formats.po.`in` import io.tolgee.formats.po.`in`.PoParser +import io.tolgee.model.dataImport.issues.issueTypes.FileIssueType import io.tolgee.util.FileProcessorContextMockUtil import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach @@ -12,11 +13,11 @@ class PoParserTest { @BeforeEach fun setup() { mockUtil = FileProcessorContextMockUtil() - mockUtil.mockIt("example.po", "src/test/resources/import/po/example.po") } @Test fun `returns correct parsed result`() { + mockUtil.mockIt("example.po", "src/test/resources/import/po/example.po") val result = PoParser(mockUtil.fileProcessorContext)() assertThat(result.translations).hasSizeGreaterThan(8) assertThat(result.translations[5].msgstrPlurals).hasSize(2) @@ -30,4 +31,47 @@ class PoParserTest { assertThat(result.translations[10].msgstr.toString()).isEqualTo("This\nis\na\nmultiline\nstring") assertThat(result.translations[11].msgstr.toString()).isEqualTo("This\r\nis\r\na\r\nmultiline\r\nstring") } + + @Test + fun `parses msgctxt for each entry`() { + mockUtil.mockIt("example_msgctxt.po", "src/test/resources/import/po/example_msgctxt.po") + val result = PoParser(mockUtil.fileProcessorContext)() + val byMsgid = + result.translations + .filter { it.msgid.toString().isNotEmpty() } + .associateBy { it.msgctxt.toString() to it.msgid.toString() } + + assertThat(byMsgid).containsKey("menu" to "Open") + assertThat(byMsgid).containsKey("verb" to "Open") + assertThat(byMsgid).containsKey("" to "Open") + assertThat(byMsgid["items" to "%d item"]?.msgidPlural?.toString()).isEqualTo("%d items") + } + + @Test + fun `parses msgctxt containing escaped quotes and newlines`() { + mockUtil.mockIt("example_msgctxt_escapes.po", "src/test/resources/import/po/example_msgctxt_escapes.po") + val result = PoParser(mockUtil.fileProcessorContext)() + val entry = result.translations.first { it.msgid.toString() == "Save" } + assertThat(entry.msgctxt.toString()).isEqualTo("a \"quoted\" ctx\nwith newline") + } + + @Test + fun `does not emit PO_MSGCTXT_NOT_SUPPORTED issue`() { + mockUtil.mockIt("example_msgctxt.po", "src/test/resources/import/po/example_msgctxt.po") + PoParser(mockUtil.fileProcessorContext)() + val emittedTypes = + mockUtil.fileProcessorContext.fileEntity.issues + .map { it.type } + assertThat(emittedTypes).doesNotContain(FileIssueType.PO_MSGCTXT_NOT_SUPPORTED) + } + + @Test + fun `does not promote msgctxt-only entry with empty msgid to header`() { + mockUtil.mockIt("example_msgctxt_only.po", "src/test/resources/import/po/example_msgctxt_only.po") + val result = PoParser(mockUtil.fileProcessorContext)() + // The msgctxt-only-with-empty-msgid entry must not contribute header metadata + // (would otherwise be parsed as Key: value lines from its msgstr). + assertThat(result.meta.language).isEqualTo("de") + assertThat(result.meta.other).doesNotContainKey("garbage") + } } diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoFileExporterTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoFileExporterTest.kt index 435c02ae7ea..89c970c77a6 100644 --- a/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoFileExporterTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/formats/po/out/PoFileExporterTest.kt @@ -3,6 +3,7 @@ package io.tolgee.unit.formats.po.out import io.tolgee.dtos.request.export.ExportParams import io.tolgee.formats.ExportFormat import io.tolgee.formats.ExportMessageFormat +import io.tolgee.formats.po.PO_MSGCTXT_KEY_SEPARATOR import io.tolgee.formats.po.out.PoFileExporter import io.tolgee.model.ILanguage import io.tolgee.model.enums.TranslationState @@ -169,6 +170,114 @@ class PoFileExporterTest { ) } + @Test + fun `exports msgctxt for keys containing separator`() { + val data = + getExported( + getExporter( + listOf( + translationView(1, "menu${PO_MSGCTXT_KEY_SEPARATOR}Open", "Öffnen"), + translationView(2, "verb${PO_MSGCTXT_KEY_SEPARATOR}Open", "Öffnen (Verb)"), + translationView(3, "Open", "Öffnen (kein Kontext)"), + ), + ), + ) + data.assertFile( + "de.po", + """ + |msgid "" + |msgstr "" + |"Language: de\n" + |"MIME-Version: 1.0\n" + |"Content-Type: text/plain; charset=UTF-8\n" + |"Content-Transfer-Encoding: 8bit\n" + |"Plural-Forms: nplurals=2; plural=(n != 1)\n" + |"X-Generator: Tolgee\n" + | + |msgctxt "menu" + |msgid "Open" + |msgstr "Öffnen" + | + |msgctxt "verb" + |msgid "Open" + |msgstr "Öffnen (Verb)" + | + |msgid "Open" + |msgstr "Öffnen (kein Kontext)" + | + """.trimMargin(), + ) + } + + @Test + fun `exports plural with msgctxt and uses split msgid as msgid_plural fallback`() { + val data = + getExported( + getExporter( + listOf( + translationView( + keyId = 1, + keyName = "items${PO_MSGCTXT_KEY_SEPARATOR}%d item", + text = "{count, plural, one {# Element} other {# Elemente}}", + isPlural = true, + ), + ), + ), + ) + data.assertFile( + "de.po", + """ + |msgid "" + |msgstr "" + |"Language: de\n" + |"MIME-Version: 1.0\n" + |"Content-Type: text/plain; charset=UTF-8\n" + |"Content-Transfer-Encoding: 8bit\n" + |"Plural-Forms: nplurals=2; plural=(n != 1)\n" + |"X-Generator: Tolgee\n" + | + |msgctxt "items" + |msgid "%d item" + |msgid_plural "%d item" + |msgstr[0] "%d Element" + |msgstr[1] "%d Elemente" + | + """.trimMargin(), + ) + } + + @Test + fun `escapes quotes and newlines inside msgctxt`() { + val data = + getExported( + getExporter( + listOf( + translationView( + keyId = 1, + keyName = "a \"quoted\" ctx\nwith newline${PO_MSGCTXT_KEY_SEPARATOR}Save", + text = "Speichern", + ), + ), + ), + ) + data["de.po"].assert.contains("msgctxt ") + data["de.po"].assert.contains("\\\"quoted\\\"") + data["de.po"].assert.contains("msgid \"Save\"") + } + + private fun translationView( + keyId: Long, + keyName: String, + text: String, + isPlural: Boolean = false, + ) = ExportTranslationView( + keyId, + text, + TranslationState.TRANSLATED, + ExportKeyView(keyId, keyName, isPlural = isPlural), + "de", + ) + @Test fun `honors the provided fileStructureTemplate`() { val exporter = diff --git a/backend/data/src/test/resources/import/po/example_msgctxt.po b/backend/data/src/test/resources/import/po/example_msgctxt.po new file mode 100644 index 00000000000..efad6e44384 --- /dev/null +++ b/backend/data/src/test/resources/import/po/example_msgctxt.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "menu" +msgid "Open" +msgstr "Öffnen" + +msgctxt "verb" +msgid "Open" +msgstr "Öffnen (Verb)" + +msgid "Open" +msgstr "Öffnen (kein Kontext)" + +msgctxt "items" +msgid "%d item" +msgid_plural "%d items" +msgstr[0] "%d Element" +msgstr[1] "%d Elemente" + +msgctxt "" +msgid "Cancel" +msgstr "Abbrechen" diff --git a/backend/data/src/test/resources/import/po/example_msgctxt_escapes.po b/backend/data/src/test/resources/import/po/example_msgctxt_escapes.po new file mode 100644 index 00000000000..24da4c8b3de --- /dev/null +++ b/backend/data/src/test/resources/import/po/example_msgctxt_escapes.po @@ -0,0 +1,11 @@ +msgid "" +msgstr "" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgctxt "a \"quoted\" ctx\nwith newline" +msgid "Save" +msgstr "Speichern" diff --git a/backend/data/src/test/resources/import/po/example_msgctxt_only.po b/backend/data/src/test/resources/import/po/example_msgctxt_only.po new file mode 100644 index 00000000000..6afcb97c07d --- /dev/null +++ b/backend/data/src/test/resources/import/po/example_msgctxt_only.po @@ -0,0 +1,14 @@ +msgid "" +msgstr "" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgctxt "stray" +msgid "" +msgstr "garbage: should-not-leak-to-header\n" + +msgctxt "menu" +msgid "Open" +msgstr "Öffnen" diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PoMsgctxtE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PoMsgctxtE2eDataController.kt new file mode 100644 index 00000000000..6d3e56283f4 --- /dev/null +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PoMsgctxtE2eDataController.kt @@ -0,0 +1,11 @@ +package io.tolgee.controllers.internal.e2eData + +import io.tolgee.controllers.internal.InternalController +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.development.testDataBuilder.data.PoMsgctxtTestData + +@InternalController(["internal/e2e-data/po-msgctxt"]) +class PoMsgctxtE2eDataController : AbstractE2eDataController() { + override val testData: TestDataBuilder + get() = PoMsgctxtTestData().root +} diff --git a/e2e/cypress/common/apiCalls/testData/testData.ts b/e2e/cypress/common/apiCalls/testData/testData.ts index e64a6e8ab36..94412763de3 100644 --- a/e2e/cypress/common/apiCalls/testData/testData.ts +++ b/e2e/cypress/common/apiCalls/testData/testData.ts @@ -31,6 +31,8 @@ export const commentsTestData = generateTestDataObject('translation-comments'); export const translationSingleTestData = generateTestDataObject('translation-single'); +export const poMsgctxtTestData = generateTestDataObject('po-msgctxt'); + export const importTestData = { clean: () => cleanTestData('import'), generateBasic: () => internalFetch('e2e-data/import/generate'), diff --git a/e2e/cypress/e2e/translations/poMsgctxt.cy.ts b/e2e/cypress/e2e/translations/poMsgctxt.cy.ts new file mode 100644 index 00000000000..d0549ebb23b --- /dev/null +++ b/e2e/cypress/e2e/translations/poMsgctxt.cy.ts @@ -0,0 +1,72 @@ +import { poMsgctxtTestData } from '../../common/apiCalls/testData/testData'; +import { login } from '../../common/apiCalls/common'; +import { visitTranslations } from '../../common/translations'; +import { waitForGlobalLoading } from '../../common/loading'; +import { E2TranslationsView } from '../../compounds/E2TranslationsView'; + +const PO_MSGCTXT_KEY_SEPARATOR = '\u0004'; + +describe('PO msgctxt key names', () => { + let projectId: number; + + beforeEach(() => { + poMsgctxtTestData.clean({ failOnStatusCode: false }); + poMsgctxtTestData + .generateStandard() + .then((r) => { + projectId = r.body.projects[0].id; + }) + .then(() => login('test_username')); + }); + + afterEach(() => { + poMsgctxtTestData.clean({ failOnStatusCode: false }); + }); + + it('renders msgctxt as chip in the translations grid', () => { + visitTranslations(projectId); + waitForGlobalLoading(); + + cy.gcy('translations-key-name') + .contains('Open') + .closest('[data-cy="translations-key-name"]') + .findDcy('key-name-msgctxt') + .should('be.visible') + .and('have.text', 'menu'); + + cy.gcy('translations-key-name') + .contains('plain.key') + .closest('[data-cy="translations-key-name"]') + .findDcy('key-name-msgctxt') + .should('not.exist'); + }); + + it('pasting EOT into the key-name editor renders an msgctxt chip', () => { + visitTranslations(projectId); + waitForGlobalLoading(); + + const translationsView = new E2TranslationsView(); + const dialog = translationsView.openKeyCreateDialog(); + + dialog.getKeyNameInput().find('.cm-content').as('keyInput'); + + cy.get('@keyInput').click(); + cy.get('@keyInput').then(($el) => { + const text = `pasted${PO_MSGCTXT_KEY_SEPARATOR}Save`; + const clipboardData = new DataTransfer(); + clipboardData.setData('text/plain', text); + const event = new ClipboardEvent('paste', { + clipboardData, + bubbles: true, + cancelable: true, + }); + $el[0].dispatchEvent(event); + }); + + dialog + .getKeyNameInput() + .find('.keyname-msgctxt-widget') + .should('be.visible') + .and('have.text', 'pasted'); + }); +}); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index a2b3dfa5560..1f5bee1e360 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -359,6 +359,7 @@ declare namespace DataCy { "key-edit-tab-context": true; "key-edit-tab-custom-properties": true; "key-edit-tab-general": true; + "key-name-msgctxt": true; "key-plural-checkbox": true; "key-plural-variable-name": true; "label-autocomplete-option": true; diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 816d6a95470..641cc115de5 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -29,7 +29,7 @@ "@sentry/react": "^10.31.0", "@sentry/vite-plugin": "^4.6.1", "@stomp/stompjs": "^6.1.2", - "@tginternal/editor": "1.16.1", + "@tginternal/editor": "1.17.0", "@tginternal/library": "file:../library", "@tolgee/format-icu": "^5.27.0", "@tolgee/react": "^5.27.0", @@ -3719,9 +3719,9 @@ } }, "node_modules/@tginternal/editor": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@tginternal/editor/-/editor-1.16.1.tgz", - "integrity": "sha512-851Mheo3oIfVWpU3vPzV39xzQAc0gzRJ9hXOgYVMMfblmCcUJGIIKs8OZJGg+OXA4LVrZP1XcVzsEBPgLqvVIg==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@tginternal/editor/-/editor-1.17.0.tgz", + "integrity": "sha512-SNsPqjKIwV6vg2Yr36cPxOc8R32uCAxrwC1kOVTSUM1lALNZQQMnGVIkJR8VxvoMNtb5CEfRhV5oaZJ4lttIFg==", "peerDependencies": { "@codemirror/lint": "^6.4.2", "@codemirror/state": "^6.4.0", diff --git a/webapp/package.json b/webapp/package.json index 8a6e52a3305..e17aceffc28 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -28,7 +28,7 @@ "@sentry/react": "^10.31.0", "@sentry/vite-plugin": "^4.6.1", "@stomp/stompjs": "^6.1.2", - "@tginternal/editor": "1.16.1", + "@tginternal/editor": "1.17.0", "@tginternal/library": "file:../library", "@tolgee/format-icu": "^5.27.0", "@tolgee/react": "^5.27.0", diff --git a/webapp/src/component/KeyName/KeyName.tsx b/webapp/src/component/KeyName/KeyName.tsx new file mode 100644 index 00000000000..cb6fc41861c --- /dev/null +++ b/webapp/src/component/KeyName/KeyName.tsx @@ -0,0 +1,66 @@ +import React, { useMemo } from 'react'; +import { styled, Tooltip, useTheme } from '@mui/material'; +import { T } from '@tolgee/react'; +import { LinkReadMore } from 'tg.component/LinkReadMore'; +import { DOCS_LINKS } from 'tg.constants/docLinks'; +import { generateKeyNameStyle } from '@tginternal/editor'; + +import { splitKeyName } from 'tg.fixtures/keyName'; + +const StyledRoot = styled('span')` + display: inline; +`; + +type Props = { + name: string; + className?: string; + ['data-cy']?: string; +}; + +export const KeyName: React.FC = ({ + name, + className, + 'data-cy': dataCy, +}) => { + const theme = useTheme(); + const { msgctxt, msgid } = splitKeyName(name); + + const Wrapper = useMemo( + () => + generateKeyNameStyle({ + styled, + component: StyledRoot, + }), + [theme.palette.mode] + ); + + if (!msgctxt) { + return ( + + {name} + + ); + } + + return ( + + , + }} + /> + } + enterDelay={200} + leaveDelay={200} + > + + {msgctxt} + + + {msgid} + + ); +}; diff --git a/webapp/src/component/ScreenshotWithLabels.tsx b/webapp/src/component/ScreenshotWithLabels.tsx index f9b0ea1450c..dd9accc48d8 100644 --- a/webapp/src/component/ScreenshotWithLabels.tsx +++ b/webapp/src/component/ScreenshotWithLabels.tsx @@ -1,5 +1,6 @@ /* eslint-disable react/no-unknown-property */ import { Tooltip, useTheme } from '@mui/material'; +import { KeyName } from 'tg.component/KeyName/KeyName'; import { CSSProperties, useEffect, useState } from 'react'; import { useImagePreload } from 'tg.fixtures/useImagePreload'; @@ -113,7 +114,11 @@ export const ScreenshotWithLabels: React.FC = ({ ); if (showTooltips) { return ( - + } + placement="right" + > {rectangle} ); diff --git a/webapp/src/component/activity/references/KeyReference.tsx b/webapp/src/component/activity/references/KeyReference.tsx index c365916ced7..718eecea277 100644 --- a/webapp/src/component/activity/references/KeyReference.tsx +++ b/webapp/src/component/activity/references/KeyReference.tsx @@ -8,6 +8,7 @@ import { useProject } from 'tg.hooks/useProject'; import { queryEncode } from 'tg.hooks/useUrlSearchState'; import { KeyReferenceData } from '../types'; import { CircledLanguageIcon } from 'tg.component/languages/CircledLanguageIcon'; +import { KeyName } from 'tg.component/KeyName/KeyName'; type Props = { data: KeyReferenceData; @@ -50,7 +51,7 @@ export const KeyReference: React.FC = ({ data }) => { {data.namespace && ( {data.namespace} )} - {data.keyName} + {data.languages && ' '} {uniqueLanguages.map((l, i) => ( diff --git a/webapp/src/component/editor/Editor.tsx b/webapp/src/component/editor/Editor.tsx index 72458276cf1..0fecc0e5f2b 100644 --- a/webapp/src/component/editor/Editor.tsx +++ b/webapp/src/component/editor/Editor.tsx @@ -1,6 +1,6 @@ import { RefObject, useEffect, useMemo, useRef } from 'react'; import { minimalSetup } from 'codemirror'; -import { Compartment, EditorState, Prec } from '@codemirror/state'; +import { Compartment, EditorState, Prec, Extension } from '@codemirror/state'; import { EditorView, ViewUpdate, keymap, KeyBinding } from '@codemirror/view'; import { styled, useTheme } from '@mui/material'; import { @@ -9,6 +9,8 @@ import { TolgeeHighlight, htmlIsolatesPlugin, generatePlaceholdersStyle, + KeyNamePlugin, + generateKeyNameStyle, } from '@tginternal/editor'; import { Direction } from 'tg.fixtures/getLanguageDirection'; @@ -59,7 +61,7 @@ export type EditorProps = { value: string; onChange?: (val: string) => void; background?: string; - mode: 'placeholders' | 'syntax' | 'plain'; + mode: 'placeholders' | 'syntax' | 'plain' | 'keyName'; autofocus?: boolean; minHeight?: number | string; onBlur?: () => void; @@ -110,11 +112,20 @@ export const Editor: React.FC = ({ const languageCompartment = useRef(new Compartment()); const StyledEditorWrapper = useMemo(() => { - return generatePlaceholdersStyle({ + // Compose both placeholder and keyName styles unconditionally — switching + // the wrapper based on `mode` would unmount the styled component on every + // mode change and tear down the underlying CodeMirror EditorView with it, + // breaking any test or interaction that toggles modes mid-edit. + const withPlaceholders = generatePlaceholdersStyle({ styled, colors: theme.palette.placeholders, component: StyledEditor, }); + return generateKeyNameStyle({ + styled, + colors: theme.palette.placeholders.variant, + component: withPlaceholders, + }); }, [theme.palette.placeholders]); keyBindings.current = shortcuts; @@ -169,22 +180,29 @@ export const Editor: React.FC = ({ }, [theme.palette.mode]); useEffect(() => { - const placholderPlugins = - mode === 'placeholders' - ? [ - PlaceholderPlugin({ - examplePluralNum, - nested: Boolean(nested), - tooltips: true, - }), - ] - : []; + const placeholderPlugins: Extension[] = []; + switch (mode) { + case 'placeholders': + placeholderPlugins.push( + PlaceholderPlugin({ + examplePluralNum, + nested: Boolean(nested), + tooltips: true, + }) + ); + break; + case 'keyName': + placeholderPlugins.push(KeyNamePlugin()); + break; + } const syntaxPlugins = - mode === 'plain' ? [] : [tolgeeSyntax(Boolean(nested))]; + mode === 'plain' || mode === 'keyName' + ? [] + : [tolgeeSyntax(Boolean(nested))]; editor.current?.dispatch({ selection: editor.current.state.selection, effects: [ - placeholders.current?.reconfigure(placholderPlugins), + placeholders.current?.reconfigure(placeholderPlugins), languageCompartment.current.reconfigure(syntaxPlugins), ], }); diff --git a/webapp/src/constants/docLinks.ts b/webapp/src/constants/docLinks.ts index 98a76015419..cd57ea838ea 100644 --- a/webapp/src/constants/docLinks.ts +++ b/webapp/src/constants/docLinks.ts @@ -5,4 +5,5 @@ export const DOCS_LINKS = { importingPlaceholders: `${DOCS_ROOT}/platform/projects_and_organizations/import#importing-placeholders`, qaChecksGrammar: `${DOCS_ROOT}/platform/translation_process/qa_checks#spelling-and-grammar-checks`, qaChecksSpelling: `${DOCS_ROOT}/platform/translation_process/qa_checks#spelling-and-grammar-checks`, + poMsgctxt: `${DOCS_ROOT}/platform/formats/po#message-context-msgctxt-and-key-names`, }; diff --git a/webapp/src/ee/branching/merge/components/changes/MergeKeyHeader.tsx b/webapp/src/ee/branching/merge/components/changes/MergeKeyHeader.tsx index 569b3a69c1a..f22b9f531ff 100644 --- a/webapp/src/ee/branching/merge/components/changes/MergeKeyHeader.tsx +++ b/webapp/src/ee/branching/merge/components/changes/MergeKeyHeader.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, styled } from '@mui/material'; import ReactMarkdown from 'react-markdown'; +import { KeyName } from 'tg.component/KeyName/KeyName'; import clsx from 'clsx'; import { MinusCircle, PlusCircle } from '@untitled-ui/icons-react'; @@ -80,7 +81,8 @@ export const MergeKeyHeader: React.FC = ({ data, variant }) => { - {[data.namespace, data.keyName].filter(Boolean).join('.')} + {data.namespace && <>{data.namespace}.} + {data.keyDescription && ( diff --git a/webapp/src/ee/translationMemory/components/content/TranslationMemoryEntryRow.tsx b/webapp/src/ee/translationMemory/components/content/TranslationMemoryEntryRow.tsx index 35aa9e0a58c..7c08bcc09f2 100644 --- a/webapp/src/ee/translationMemory/components/content/TranslationMemoryEntryRow.tsx +++ b/webapp/src/ee/translationMemory/components/content/TranslationMemoryEntryRow.tsx @@ -4,6 +4,7 @@ import { T } from '@tolgee/react'; import { FlagImage } from '@tginternal/library/components/languages/FlagImage'; import { languageInfo } from '@tginternal/language-util/lib/generated/languageInfo'; import { LimitedHeightText } from 'tg.component/LimitedHeightText'; +import { KeyName } from 'tg.component/KeyName/KeyName'; import { components } from 'tg.service/apiSchema.generated'; import { SelectionService } from 'tg.service/useSelectionService'; import { ProjectLink } from 'tg.component/ProjectLink'; @@ -163,7 +164,7 @@ export const TranslationMemoryEntryRow: React.VFC = ({ rows have no key of their own and stay unlabeled. */} {row.keyName && ( - {row.keyName} + )} diff --git a/webapp/src/fixtures/__tests__/keyName.test.ts b/webapp/src/fixtures/__tests__/keyName.test.ts new file mode 100644 index 00000000000..d39b0cb4504 --- /dev/null +++ b/webapp/src/fixtures/__tests__/keyName.test.ts @@ -0,0 +1,33 @@ +import { PO_MSGCTXT_KEY_SEPARATOR, splitKeyName } from '../keyName'; + +describe('splitKeyName', () => { + it('returns only msgid for name without separator', () => { + expect(splitKeyName('plain.key')).toEqual({ msgid: 'plain.key' }); + }); + + it('splits name into msgctxt and msgid on first separator', () => { + expect(splitKeyName(`menu${PO_MSGCTXT_KEY_SEPARATOR}Open`)).toEqual({ + msgctxt: 'menu', + msgid: 'Open', + }); + }); + + it('treats only the first separator as boundary; later occurrences belong to msgid', () => { + const name = `ctx${PO_MSGCTXT_KEY_SEPARATOR}id${PO_MSGCTXT_KEY_SEPARATOR}rest`; + expect(splitKeyName(name)).toEqual({ + msgctxt: 'ctx', + msgid: `id${PO_MSGCTXT_KEY_SEPARATOR}rest`, + }); + }); + + it('returns empty msgctxt when separator is at start', () => { + expect(splitKeyName(`${PO_MSGCTXT_KEY_SEPARATOR}Cancel`)).toEqual({ + msgctxt: '', + msgid: 'Cancel', + }); + }); + + it('returns empty msgid for empty input', () => { + expect(splitKeyName('')).toEqual({ msgid: '' }); + }); +}); diff --git a/webapp/src/fixtures/keyName.ts b/webapp/src/fixtures/keyName.ts new file mode 100644 index 00000000000..fb132b83454 --- /dev/null +++ b/webapp/src/fixtures/keyName.ts @@ -0,0 +1,14 @@ +import { PO_MSGCTXT_KEY_SEPARATOR } from '@tginternal/editor'; + +export { PO_MSGCTXT_KEY_SEPARATOR }; + +export type SplitKeyName = { + msgctxt?: string; + msgid: string; +}; + +export function splitKeyName(name: string): SplitKeyName { + const i = name.indexOf(PO_MSGCTXT_KEY_SEPARATOR); + if (i < 0) return { msgid: name }; + return { msgctxt: name.slice(0, i), msgid: name.slice(i + 1) }; +} diff --git a/webapp/src/views/projects/import/component/ImportConflictsData.tsx b/webapp/src/views/projects/import/component/ImportConflictsData.tsx index 51f02dfee96..44d30febe6f 100644 --- a/webapp/src/views/projects/import/component/ImportConflictsData.tsx +++ b/webapp/src/views/projects/import/component/ImportConflictsData.tsx @@ -1,4 +1,5 @@ import React, { FunctionComponent, useState } from 'react'; +import { KeyName } from 'tg.component/KeyName/KeyName'; import { Box, Grid, Pagination, Typography } from '@mui/material'; import { T } from '@tolgee/react'; @@ -57,7 +58,9 @@ export const ImportConflictsData: FunctionComponent<{ variant={'body2'} data-cy="import-resolution-dialog-key-name" > - {t.keyName} + + + diff --git a/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx b/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx index 7c8e35d8652..8156348bc0a 100644 --- a/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx +++ b/webapp/src/views/projects/import/component/ImportTranslationsDialog.tsx @@ -1,4 +1,5 @@ import React, { FunctionComponent, useState } from 'react'; +import { KeyName } from 'tg.component/KeyName/KeyName'; import { Box, Grid, styled } from '@mui/material'; import Dialog from '@mui/material/Dialog'; import IconButton from '@mui/material/IconButton'; @@ -116,7 +117,9 @@ export const ImportTranslationsDialog: FunctionComponent<{ > - {i.keyName} + + + {i.keyDescription && ( {i.keyDescription} diff --git a/webapp/src/views/projects/translations/KeyCellContent.tsx b/webapp/src/views/projects/translations/KeyCellContent.tsx index b76346b876f..795240ab380 100644 --- a/webapp/src/views/projects/translations/KeyCellContent.tsx +++ b/webapp/src/views/projects/translations/KeyCellContent.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'; import { LimitedHeightText } from 'tg.component/LimitedHeightText'; import { MarkdownLink } from 'tg.component/common/MarkdownLink'; +import { KeyName } from 'tg.component/KeyName/KeyName'; export const StyledKey = styled('div')` grid-area: key; @@ -37,7 +38,7 @@ export const KeyCellContent: React.FC = ({ <> - {keyName} + {description && ( diff --git a/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx b/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx index 0bd2e1339d1..a3a400f7ffc 100644 --- a/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx +++ b/webapp/src/views/projects/translations/KeyCreateForm/FormBody.tsx @@ -121,7 +121,7 @@ export const FormBody: React.FC = ({ onCancel, autofocus }) => { { form.setFieldValue(field.name, val); diff --git a/webapp/src/views/projects/translations/KeyEdit/KeyGeneral.tsx b/webapp/src/views/projects/translations/KeyEdit/KeyGeneral.tsx index 2991433e25f..037144b792b 100644 --- a/webapp/src/views/projects/translations/KeyEdit/KeyGeneral.tsx +++ b/webapp/src/views/projects/translations/KeyEdit/KeyGeneral.tsx @@ -71,7 +71,7 @@ export const KeyGeneral = () => { run: () => (submitForm(), true), }, ]} - mode="plain" + mode="keyName" minHeight="unset" /> diff --git a/webapp/src/views/projects/translations/KeySingle/KeySingle.tsx b/webapp/src/views/projects/translations/KeySingle/KeySingle.tsx index 3cf391bb608..eb105508c4c 100644 --- a/webapp/src/views/projects/translations/KeySingle/KeySingle.tsx +++ b/webapp/src/views/projects/translations/KeySingle/KeySingle.tsx @@ -1,4 +1,5 @@ import { styled } from '@mui/material'; +import { KeyName } from 'tg.component/KeyName/KeyName'; import { T, useTranslate } from '@tolgee/react'; import { useQueryClient } from 'react-query'; import { useHistory } from 'react-router-dom'; @@ -97,7 +98,7 @@ export const KeySingle: React.FC = ({ keyName, keyId }) => { ], [ keyExists ? ( - translation!.keyName + ) : ( ), diff --git a/webapp/src/views/projects/translations/SimpleCellKey.tsx b/webapp/src/views/projects/translations/SimpleCellKey.tsx index 63d63059b9f..4f3768c12cc 100644 --- a/webapp/src/views/projects/translations/SimpleCellKey.tsx +++ b/webapp/src/views/projects/translations/SimpleCellKey.tsx @@ -8,6 +8,7 @@ import { Screenshots } from './Screenshots/Screenshots'; import ReactMarkdown from 'react-markdown'; import { MarkdownLink } from 'tg.component/common/MarkdownLink'; import clsx from 'clsx'; +import { KeyName } from 'tg.component/KeyName/KeyName'; type KeyWithTranslationsModel = components['schemas']['KeyWithTranslationsModel']; @@ -52,7 +53,7 @@ export const SimpleCellKey: React.FC = ({ data }) => { > - {data.keyName} + {data.keyDescription && ( diff --git a/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemoryItem.tsx b/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemoryItem.tsx index 92c7325f400..8c793f9ac90 100644 --- a/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemoryItem.tsx +++ b/webapp/src/views/projects/translations/ToolsPanel/panels/TranslationMemory/TranslationMemoryItem.tsx @@ -1,4 +1,5 @@ import { styled, Tooltip } from '@mui/material'; +import { KeyName } from 'tg.component/KeyName/KeyName'; import { components } from 'tg.service/apiSchema.generated'; import { useTimeDistance } from 'tg.hooks/useTimeDistance'; import { TranslationWithPlaceholders } from 'tg.views/projects/translations/translationVisual/TranslationWithPlaceholders'; @@ -279,7 +280,7 @@ export const TranslationMemoryItem = ({ parts.push( - {item.keyName} + ); diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 2a36c070441..9c4b7589537 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -44,6 +44,9 @@ export default defineConfig(({ mode }) => { resolve: { preserveSymlinks: true, dedupe: [ + '@codemirror/lint', + '@codemirror/state', + '@codemirror/view', '@emotion/react', '@emotion/styled', '@mui/material',