From 1a1f50c5d3357b8830e01309a8af3fdcd6b1e9d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 26 May 2026 15:45:22 +0200 Subject: [PATCH 1/6] feat: support gettext msgctxt in PO import/export and UI Import and export of .po files now preserves the msgctxt field. Tolgee stores the pair (msgctxt, msgid) as a single key name using the U+0004 EOT separator (same convention GNU Gettext uses internally in MO files), so two entries with the same msgid but different msgctxt remain distinct keys. The UI renders the msgctxt portion as a styled chip in front of the msgid in all key-name display sites and inside the key-name editor (via a new CodeMirror mode), so display and edit look identical. --- .../kotlin/io/tolgee/formats/po/contsants.kt | 2 + .../tolgee/formats/po/in/PoFileProcessor.kt | 14 ++- .../io/tolgee/formats/po/in/PoParser.kt | 16 +-- .../formats/po/in/data/PoParsedTranslation.kt | 1 + .../tolgee/formats/po/out/PoFileExporter.kt | 24 +++- .../unit/formats/po/in/PoFileProcessorTest.kt | 38 ++++++ .../tolgee/unit/formats/po/in/PoParserTest.kt | 44 ++++++- .../unit/formats/po/out/PoFileExporterTest.kt | 109 ++++++++++++++++++ .../resources/import/po/example_msgctxt.po | 28 +++++ .../import/po/example_msgctxt_escapes.po | 11 ++ .../import/po/example_msgctxt_only.po | 14 +++ e2e/cypress/support/dataCyType.d.ts | 1 + webapp/src/component/KeyName/KeyName.tsx | 66 +++++++++++ webapp/src/component/ScreenshotWithLabels.tsx | 7 +- .../activity/references/KeyReference.tsx | 3 +- webapp/src/component/editor/Editor.tsx | 46 +++++--- webapp/src/constants/docLinks.ts | 1 + .../components/changes/MergeKeyHeader.tsx | 4 +- .../content/TranslationMemoryEntryRow.tsx | 3 +- webapp/src/fixtures/__tests__/keyName.test.ts | 33 ++++++ webapp/src/fixtures/keyName.ts | 14 +++ .../import/component/ImportConflictsData.tsx | 5 +- .../component/ImportTranslationsDialog.tsx | 5 +- .../projects/translations/KeyCellContent.tsx | 3 +- .../translations/KeyCreateForm/FormBody.tsx | 2 +- .../translations/KeyEdit/KeyGeneral.tsx | 2 +- .../translations/KeySingle/KeySingle.tsx | 3 +- .../projects/translations/SimpleCellKey.tsx | 3 +- .../TranslationMemoryItem.tsx | 3 +- webapp/vite.config.ts | 3 + 30 files changed, 469 insertions(+), 39 deletions(-) create mode 100644 backend/data/src/test/resources/import/po/example_msgctxt.po create mode 100644 backend/data/src/test/resources/import/po/example_msgctxt_escapes.po create mode 100644 backend/data/src/test/resources/import/po/example_msgctxt_only.po create mode 100644 webapp/src/component/KeyName/KeyName.tsx create mode 100644 webapp/src/fixtures/__tests__/keyName.test.ts create mode 100644 webapp/src/fixtures/keyName.ts 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..0585d59bf4e 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 != null) { + 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..ea07cb8c329 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,45 @@ 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..e4a767b37a3 --- /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/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/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..9812aef7e9b 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,12 +112,19 @@ export const Editor: React.FC = ({ const languageCompartment = useRef(new Compartment()); const StyledEditorWrapper = useMemo(() => { + if (mode === 'keyName') { + return generateKeyNameStyle({ + styled, + colors: theme.palette.placeholders.variant, + component: StyledEditor, + }); + } return generatePlaceholdersStyle({ styled, colors: theme.palette.placeholders, component: StyledEditor, }); - }, [theme.palette.placeholders]); + }, [theme.palette.placeholders, mode]); keyBindings.current = shortcuts; @@ -169,22 +178,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', From fed059d7e34142f18235c6328b798e300efbe719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 26 May 2026 16:19:51 +0200 Subject: [PATCH 2/6] test: ktlint format PoParserTest --- .../test/kotlin/io/tolgee/unit/formats/po/in/PoParserTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ea07cb8c329..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 @@ -59,7 +59,9 @@ class PoParserTest { 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 } + val emittedTypes = + mockUtil.fileProcessorContext.fileEntity.issues + .map { it.type } assertThat(emittedTypes).doesNotContain(FileIssueType.PO_MSGCTXT_NOT_SUPPORTED) } From 1b52307c6cacc87168806287f5f7ee4e67d76b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 27 May 2026 10:15:39 +0200 Subject: [PATCH 3/6] chore: bump @tginternal/editor to 1.17.0 Pulls in KeyNamePlugin, generateKeyNameStyle and the PO_MSGCTXT_KEY_SEPARATOR constant required for the msgctxt chip rendering. --- webapp/package-lock.json | 8 ++++---- webapp/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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", From e4b3422240fd1fa38b99f07bab787fcdb0fe00d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 27 May 2026 10:15:40 +0200 Subject: [PATCH 4/6] test: add e2e for msgctxt chip in key name Adds PoMsgctxtTestData with two keys (one plain, one encoding a gettext msgctxt via the U+0004 separator) and an e2e suite that verifies: - the chip widget is rendered next to the msgid in the translations grid, and is absent for plain keys - pasting a string containing the U+0004 separator into the key-name editor produces the same chip widget inside the editor --- .../testDataBuilder/data/PoMsgctxtTestData.kt | 19 +++++ .../e2eData/PoMsgctxtE2eDataController.kt | 11 +++ .../common/apiCalls/testData/testData.ts | 2 + e2e/cypress/e2e/translations/poMsgctxt.cy.ts | 72 +++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PoMsgctxtTestData.kt create mode 100644 backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PoMsgctxtE2eDataController.kt create mode 100644 e2e/cypress/e2e/translations/poMsgctxt.cy.ts 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/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'); + }); +}); From c7c92389a722f4f078dbec377fdc8534df39d2e5 Mon Sep 17 00:00:00 2001 From: Jiri Kuchynka Date: Wed, 27 May 2026 10:29:57 +0200 Subject: [PATCH 5/6] fix: apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/main/kotlin/io/tolgee/formats/po/out/PoFileExporter.kt | 2 +- .../src/test/resources/import/po/example_msgctxt_escapes.po | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 0585d59bf4e..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 @@ -53,7 +53,7 @@ class PoFileExporter( } private fun StringBuilder.writeMsgCtxt(msgctxt: String?) { - if (msgctxt != null) { + if (!msgctxt.isNullOrEmpty()) { this.append(convertToPoMultilineString("msgctxt", msgctxt)) } } 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 index e4a767b37a3..24da4c8b3de 100644 --- a/backend/data/src/test/resources/import/po/example_msgctxt_escapes.po +++ b/backend/data/src/test/resources/import/po/example_msgctxt_escapes.po @@ -4,7 +4,7 @@ msgstr "" "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" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" msgctxt "a \"quoted\" ctx\nwith newline" msgid "Save" From 7e283f42e311b87ba1cac95a2e33c98ae75dbf60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Wed, 27 May 2026 11:21:46 +0200 Subject: [PATCH 6/6] fix: compose both placeholder and keyName styles unconditionally Switching the StyledEditorWrapper based on `mode` rebuilt the styled component on every mode change, which made React remount the wrapper and tore down the CodeMirror EditorView along with it. This broke any flow that toggled modes mid-edit (e.g. switchToSyntaxMode in the key create dialog used by the char limit e2e tests). Build a single wrapper that applies both sets of styles and keep useMemo dependent only on the palette. --- webapp/src/component/editor/Editor.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/webapp/src/component/editor/Editor.tsx b/webapp/src/component/editor/Editor.tsx index 9812aef7e9b..0fecc0e5f2b 100644 --- a/webapp/src/component/editor/Editor.tsx +++ b/webapp/src/component/editor/Editor.tsx @@ -112,19 +112,21 @@ export const Editor: React.FC = ({ const languageCompartment = useRef(new Compartment()); const StyledEditorWrapper = useMemo(() => { - if (mode === 'keyName') { - return generateKeyNameStyle({ - styled, - colors: theme.palette.placeholders.variant, - component: StyledEditor, - }); - } - 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, }); - }, [theme.palette.placeholders, mode]); + return generateKeyNameStyle({ + styled, + colors: theme.palette.placeholders.variant, + component: withPlaceholders, + }); + }, [theme.palette.placeholders]); keyBindings.current = shortcuts;