diff --git a/src/utils/po-surgical.ts b/src/utils/po-surgical.ts index 479da25..8edc359 100644 --- a/src/utils/po-surgical.ts +++ b/src/utils/po-surgical.ts @@ -233,6 +233,25 @@ function hasNewEntriesToAdd( return false; // No new entries to add } +// msgfmt drops fuzzy entries from the compiled catalog, so a stale flag hides the +// translation we just wrote. Walks backwards so a splice cannot shift an unvisited line. +function clearFuzzyFlag(result: string[], entryContentStartIndex: number): void { + for (let i = result.length - 1; i >= entryContentStartIndex; i--) { + const line = result[i]; + if (!line.startsWith('#,')) continue; + + const flags = line.slice(2).split(',').map(flag => flag.trim()).filter(Boolean); + const remaining = flags.filter(flag => flag !== 'fuzzy'); + if (remaining.length === flags.length) continue; + + if (remaining.length > 0) { + result[i] = `#, ${remaining.join(', ')}`; + } else { + result.splice(i, 1); + } + } +} + enum State { IDLE, IN_MSGCTXT, @@ -271,6 +290,7 @@ function processLineByLine( const result: string[] = []; const parsed = po.parse(content); const changesToMake = new Map(); + const translatedEntries = new Set(); // Entries receiving a non-empty translation, by their own uniqueKey const msgidChanges = new Map(); // Map old msgid → new msgid (for versioning) const entriesToRemove = new Set(); // Entries to remove when target msgid already exists (merge case) @@ -323,6 +343,7 @@ function processLineByLine( if (currentValue !== normalizedNewValue || foundViaMapping) { changesToMake.set(uniqueKey, newValue); + translatedEntries.add(uniqueKey); // Also track the new key so addNewEntries doesn't add it as a duplicate if (foundViaMapping && actualNewKey !== uniqueKey) { changesToMake.set(actualNewKey, newValue); @@ -346,6 +367,7 @@ function processLineByLine( if (currentPluralValue !== normalizedNewPluralValue || foundViaMapping) { changesToMake.set(oldPluralKey, newPluralValue); + translatedEntries.add(uniqueKey); // Also track the new plural key so addNewEntries doesn't add it as a duplicate if (foundViaMapping && newPluralKey !== oldPluralKey) { changesToMake.set(newPluralKey, newPluralValue); @@ -361,6 +383,7 @@ function processLineByLine( if (currentPluralValue !== normalizedNewPluralValue) { changesToMake.set(pluralKey, translations[pluralKey]); + translatedEntries.add(uniqueKey); } } } @@ -467,6 +490,10 @@ function processLineByLine( continue; } + if (translatedEntries.has(uniqueKey)) { + clearFuzzyFlag(result, entryContentStartIndex); + } + const newMsgid = msgidChanges.get(uniqueKey); if (newMsgid) { currentEntry.versionedNewMsgid = newMsgid; diff --git a/src/utils/translation-updater/po-handler.ts b/src/utils/translation-updater/po-handler.ts index f56bbf2..ca069cb 100644 --- a/src/utils/translation-updater/po-handler.ts +++ b/src/utils/translation-updater/po-handler.ts @@ -87,6 +87,15 @@ function commentsFromMetadata(metadata: TranslationWithMetadata['metadata']): Po return Object.keys(comments).length > 0 ? comments : undefined; } +function withoutFuzzy(comments: PoEntry['comments']): PoEntry['comments'] { + if (!comments?.flag) return comments; + + const { flag, ...rest } = comments; + const kept = flag.split(',').map(item => item.trim()).filter(item => item && item !== 'fuzzy'); + const next = kept.length > 0 ? { ...rest, flag: kept.join(', ') } : rest; + return Object.keys(next).length > 0 ? next : undefined; +} + function referencesByKey( metadataByKey: Map ): Record { @@ -236,6 +245,13 @@ export async function updatePoFile( entriesByKey.set(baseKey, entry); } + // After all forms are in, so order cannot matter. + for (const entry of entriesByKey.values()) { + if (entry.msgstr.some(form => form !== '')) { + entry.comments = withoutFuzzy(entry.comments); + } + } + const entries = Array.from(entriesByKey.values()); for (const key of pluralEntryKeys) { const entry = entriesByKey.get(key); diff --git a/tests/utils/po-handler.test.js b/tests/utils/po-handler.test.js index 3d290c7..cdf040b 100644 --- a/tests/utils/po-handler.test.js +++ b/tests/utils/po-handler.test.js @@ -27,6 +27,66 @@ describe('po-handler', () => { }); describe('updatePoFile', () => { + it('plural forms keep the first form\'s comments when later forms carry none', async () => { + const targetFilePath = path.join(tempDir, 'target.po'); + const translations = [ + { + key: 'item', + value: 'sak', + metadata: { po_plural: true, plural_index: 0, msgid_plural: 'items', source_references: ['app/views.py:10'] } + }, + { + key: 'item__plural_1', + value: 'saker', + metadata: { po_plural: true, plural_index: 1, msgid: 'item' } + } + ]; + + await updatePoFile(targetFilePath, translations, 'sv'); + + const written = await fs.readFile(targetFilePath, 'utf-8'); + assertValidPo(written); + expect(written).toContain('#: app/views.py:10'); + }); + + it('clears fuzzy on a plural entry when a translated form precedes an empty one', async () => { + const targetFilePath = path.join(tempDir, 'target.po'); + const translations = [ + { key: 'item', value: 'sak', metadata: { po_plural: true, plural_index: 0, msgid_plural: 'items', po_flags: ['fuzzy'] } }, + { key: 'item__plural_1', value: '', metadata: { po_plural: true, plural_index: 1, msgid: 'item', po_flags: ['fuzzy'] } } + ]; + + await updatePoFile(targetFilePath, translations, 'sv'); + + const written = await fs.readFile(targetFilePath, 'utf-8'); + assertValidPo(written); + expect(written).toContain('msgstr[0] "sak"'); + expect(written).not.toContain('fuzzy'); + }); + + it('does not stamp fuzzy onto a translation it just wrote, and keeps it on an empty one', async () => { + const targetFilePath = path.join(tempDir, 'target.po'); + const translations = [ + { + key: 'Copyright notice', + value: 'Upphovsrattsmeddelande', + metadata: { po_flags: ['fuzzy', 'python-format'] } + }, + { + key: 'Untouched', + value: '', + metadata: { po_flags: ['fuzzy'] } + } + ]; + + await updatePoFile(targetFilePath, translations, 'sv'); + + const written = await fs.readFile(targetFilePath, 'utf-8'); + assertValidPo(written); + expect(written).toMatch(/#, python-format\nmsgid "Copyright notice"\nmsgstr "Upphovsrattsmeddelande"/); + expect(written).toMatch(/#, fuzzy\nmsgid "Untouched"\nmsgstr ""/); + }); + it('preserves Language header from source file', async () => { const sourceFilePath = path.join(tempDir, 'source.po'); const sourceContent = `msgid "" diff --git a/tests/utils/po-surgical.test.js b/tests/utils/po-surgical.test.js index 3d873e6..e906b48 100644 --- a/tests/utils/po-surgical.test.js +++ b/tests/utils/po-surgical.test.js @@ -48,6 +48,74 @@ describe('po-surgical', () => { }); }); + describe('Fuzzy flags', () => { + test('clears fuzzy when a real translation is written', () => { + const original = loadFixture('lingui-messages'); + expect(original).toContain('#, fuzzy'); + + const result = surgicalUpdatePoFile(original, { + 'Copyright notice': 'Upphovsrattsmeddelande' + }); + + expect(result).toContain('msgstr "Upphovsrattsmeddelande"'); + expect(result).not.toContain('#, fuzzy'); + }); + + test('clears fuzzy from every #, line of the entry', () => { + const original = `msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\\n" + +#, python-format +#, fuzzy +#, fuzzy +msgid "Hello %(name)s" +msgstr "Hallo %(name)s" +`; + const result = surgicalUpdatePoFile(original, { 'Hello %(name)s': 'Hej %(name)s' }); + + expect(result).toContain('msgstr "Hej %(name)s"'); + expect(result).not.toContain('fuzzy'); + expect(result).toMatch(/#, python-format\nmsgid "Hello %\(name\)s"/); + }); + + test('clears fuzzy on the owning entry when translated via a __plural_N key', () => { + const original = `msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" + +#, fuzzy +msgid "foo" +msgid_plural "foos" +msgstr[0] "guessed one" +msgstr[1] "guessed many" +`; + const result = surgicalUpdatePoFile(original, { 'foo__plural_1': 'saker' }); + + expect(result).toContain('msgstr[1] "saker"'); + expect(result).not.toContain('fuzzy'); + }); + + test('clears fuzzy on the owning entry when translated via its msgid_plural key', () => { + const original = `msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" + +#, fuzzy +msgid "item" +msgid_plural "items" +msgstr[0] "guessed one" +msgstr[1] "guessed many" +`; + const result = surgicalUpdatePoFile(original, { 'items': 'saker' }); + + expect(result).toContain('msgstr[1] "saker"'); + expect(result).not.toContain('fuzzy'); + }); + }); + describe('Simple Translation Updates', () => { test('should update single translation without affecting others', () => { const original = loadFixture('simple');