Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/utils/po-surgical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -271,6 +290,7 @@ function processLineByLine(
const result: string[] = [];
const parsed = po.parse(content);
const changesToMake = new Map<string, string>();
const translatedEntries = new Set<string>(); // Entries receiving a non-empty translation, by their own uniqueKey
const msgidChanges = new Map<string, string>(); // Map old msgid → new msgid (for versioning)
const entriesToRemove = new Set<string>(); // Entries to remove when target msgid already exists (merge case)

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -361,6 +383,7 @@ function processLineByLine(

if (currentPluralValue !== normalizedNewPluralValue) {
changesToMake.set(pluralKey, translations[pluralKey]);
translatedEntries.add(uniqueKey);
}
}
}
Expand Down Expand Up @@ -467,6 +490,10 @@ function processLineByLine(
continue;
}

if (translatedEntries.has(uniqueKey)) {
clearFuzzyFlag(result, entryContentStartIndex);
}

const newMsgid = msgidChanges.get(uniqueKey);
if (newMsgid) {
currentEntry.versionedNewMsgid = newMsgid;
Expand Down
16 changes: 16 additions & 0 deletions src/utils/translation-updater/po-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, TranslationWithMetadata['metadata']>
): Record<string, string[]> {
Expand Down Expand Up @@ -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);
Expand Down
60 changes: 60 additions & 0 deletions tests/utils/po-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
68 changes: 68 additions & 0 deletions tests/utils/po-surgical.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading