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
75 changes: 70 additions & 5 deletions src/utils/po-surgical.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { po } from 'gettext-parser';
import { createUniqueKey, normalizeStringValue, parseUniqueKey, parsePoFile, PLURAL_PREFIX, extractNPlurals, MAX_PLURAL_FORMS } from './po-utils.js';
import { createUniqueKey, normalizeStringValue, parseUniqueKey, parsePoFile, parsePoFlags, PLURAL_PREFIX, extractNPlurals, MAX_PLURAL_FORMS } from './po-utils.js';

/**
* Surgical update of .po file - only modify lines that actually changed
Expand Down Expand Up @@ -32,6 +32,7 @@ export function surgicalUpdatePoFile(
// Quick check, if all translations match normalized content exactly,
// return original unchanged to preserve formatting
const parsed = po.parse(originalContent);
const nplurals = extractNPlurals(parsed.headers || {});
let allIdentical = true;

Object.entries(parsed.translations).forEach(([context, entries]) => {
Expand All @@ -42,6 +43,10 @@ export function surgicalUpdatePoFile(
const contextValue = context !== '' ? context : undefined;
const uniqueKey = createUniqueKey(msgid, contextValue);

if (fuzzyEntryIsFullyTranslated(entry, uniqueKey, translations, nplurals, options?.keyMappings?.[uniqueKey])) {
allIdentical = false;
}

if (translations[uniqueKey]) {
const newValue = translations[uniqueKey];
const currentValue = entry.msgid_plural
Expand Down Expand Up @@ -100,6 +105,57 @@ export function surgicalUpdatePoFile(
return processLineByLine(originalContent, translations, options);
}

// A fuzzy entry needs rewriting even when the translation matches the guess
// gettext made, because the flag itself is what keeps it out of the compiled
// catalog. Every existing form must be covered: clearing the flag while one
// form still holds a guess would publish that guess.
function fuzzyEntryIsFullyTranslated(
entry: any,
uniqueKey: string,
translations: Record<string, string>,
nplurals: number,
mappedKey?: string
): boolean {
if (!parsePoFlags(entry.comments?.flag)?.includes('fuzzy')) return false;

// Whitespace-only counts as empty everywhere else here, so it must not be
// enough to clear the flag either. gettext also requires msgid and msgstr to
// agree on a leading and trailing newline; a translation that breaks that
// makes the whole catalog fail to compile, so it cannot count as done.
const agreesOnEdges = (value: string, msgid: string) =>
value.startsWith('\n') === msgid.startsWith('\n') &&
value.endsWith('\n') === msgid.endsWith('\n');
const filled = (key: string, msgid: string) => {
const value = translations[key];
if (value === undefined) return false;
return normalizeStringValue(value) !== '' && agreesOnEdges(value, msgid);
};
// Versioning re-keys the payload to the new msgid, so accept either shape.
const keyed = (suffix: string, msgid: string) =>
filled(uniqueKey + suffix, msgid) || Boolean(mappedKey && filled(mappedKey + suffix, msgid));

if (!entry.msgid_plural) return keyed('', entry.msgid);

const { context } = parseUniqueKey(uniqueKey);
// Form 1 can also arrive under the msgid_plural text itself, the shape the
// detection pass below accepts for catalogs written before __plural_N keys.
const msgidPluralKey = createUniqueKey(entry.msgid_plural, context);

// Count against the header, not the slots the file happens to carry: the
// writer only fills existing slots, so a file short of its declared forms
// would otherwise lose the rest while looking fully translated.
const formCount = Math.max(entry.msgstr.length, nplurals);
for (let i = 0; i < formCount; i++) {
if (i >= entry.msgstr.length) return false;
const formMsgid = i === 0 ? entry.msgid : entry.msgid_plural;
if (i === 0 && keyed('', formMsgid)) continue;
if (keyed(`${PLURAL_PREFIX}${i}`, formMsgid)) continue;
if (i === 1 && entry.msgid_plural !== entry.msgid && filled(msgidPluralKey, formMsgid)) continue;
return false;
}
return true;
}

/**
* Group plural translations together
*/
Expand Down Expand Up @@ -289,6 +345,7 @@ function processLineByLine(
const lines = content.split('\n');
const result: string[] = [];
const parsed = po.parse(content);
const entryNplurals = extractNPlurals(parsed.headers || {});
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)
Expand Down Expand Up @@ -327,6 +384,17 @@ function processLineByLine(
return;
}

// A source-language write echoes the msgid back, leaves the entry alone,
// and must leave its flag alone too: clearing it would publish the guess
// still sitting in msgstr.
const isSourceLanguageEcho = translations[actualNewKey] === msgid &&
options?.sourceLanguage === options?.targetLanguage;

if (!isSourceLanguageEcho &&
fuzzyEntryIsFullyTranslated(entry, uniqueKey, translations, entryNplurals, actualNewKey)) {
translatedEntries.add(uniqueKey);
}

if (translations[actualNewKey]) {
const newValue = translations[actualNewKey];

Expand All @@ -343,7 +411,6 @@ 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 @@ -367,7 +434,6 @@ 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 @@ -383,15 +449,14 @@ function processLineByLine(

if (currentPluralValue !== normalizedNewPluralValue) {
changesToMake.set(pluralKey, translations[pluralKey]);
translatedEntries.add(uniqueKey);
}
}
}
});
}
});

if (changesToMake.size === 0 && entriesToRemove.size === 0) {
if (changesToMake.size === 0 && entriesToRemove.size === 0 && translatedEntries.size === 0) {
// Still need to add new entries even if no existing entries need changes
const newEntries = addNewEntries(translations, parsed, changesToMake, options);
if (newEntries.length > 0) {
Expand Down
30 changes: 27 additions & 3 deletions src/utils/po-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function normalizeReferences(reference: string | string[]): string[] {

// gettext allows flags on one `#,` line or spread across several, and
// gettext-parser joins the latter with a newline rather than a comma.
function parsePoFlags(flag: string | undefined): string[] | undefined {
export function parsePoFlags(flag: string | undefined): string[] | undefined {
if (!flag) return undefined;
const flags = flag.split(/[,\n]\s*/).map(f => f.trim()).filter(Boolean);
return flags.length > 0 ? flags : undefined;
Expand Down Expand Up @@ -218,6 +218,19 @@ export function poEntriesToApiFormat(
const isSourceLanguage = options?.sourceLanguage && options?.currentLanguage &&
options.sourceLanguage === options.currentLanguage;

// A fuzzy msgstr is gettext's guess from a different msgid, and msgfmt leaves
// it out of the compiled catalog. Uploading it would record a translation the
// app never shows, so the entry is left out and reported missing instead.
// Only when both locales are known and differ: callers that omit them (change
// detection, flag inspection) still need every entry.
const isKnownTargetLanguage = Boolean(
options?.sourceLanguage && options?.currentLanguage &&
options.sourceLanguage !== options.currentLanguage
);
if (isKnownTargetLanguage && poFlags?.includes('fuzzy')) {
return;
}

if (entry.msgid_plural) {
for (let i = 0; i < nplurals; i++) {
const suffix = i === 0 ? '' : `${PLURAL_PREFIX}${i}`;
Expand Down Expand Up @@ -361,9 +374,19 @@ export function findMissingPoTranslations(

const hasMetadata = Object.keys(metadata).length > 0;

// gettext marks an entry `fuzzy` when msgmerge guessed a translation from a
// similar msgid — usually after a source string was reworded. msgfmt excludes
// fuzzy entries from the compiled .mo, so the app renders the SOURCE string:
// the key looks translated in the file while being untranslated to the user.
// Treat it as missing so the guess is replaced, matching how Weblate,
// Transifex, Lokalise and Phrase all import fuzzy as needs-review, not done.
// Read from the TARGET entry: `poFlags` above belongs to the source entry.
const targetIsFuzzy = parsePoFlags(targetEntry?.comments?.flag)?.includes('fuzzy') ?? false;

if (entry.msgid_plural) {
for (let i = 0; i < targetNplurals; i++) {
const isEmpty = !targetEntry ||
const isEmpty = targetIsFuzzy ||
!targetEntry ||
!targetEntry.msgstr ||
!targetEntry.msgstr[i] ||
targetEntry.msgstr[i].trim() === '';
Expand All @@ -384,7 +407,8 @@ export function findMissingPoTranslations(
}
} else {
// Handle regular (non-plural) entries
const isEmpty = !targetEntry ||
const isEmpty = targetIsFuzzy ||
!targetEntry ||
!targetEntry.msgstr ||
!targetEntry.msgstr[0] ||
targetEntry.msgstr[0].trim() === '';
Expand Down
8 changes: 8 additions & 0 deletions src/utils/translation-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,14 @@ async function processBatch(
console.warn(chalk.yellow(` ❌ Job ${jobId} exceeded maximum retries (${MAX_JOB_STATUS_CHECK_ATTEMPTS}) and will be skipped.`));
}

// However it ran out of retries, the job has not delivered its
// translations. Record it, or the summary reports success for a run
// that wrote nothing.
const exhaustedLocale = jobSourceMapping[jobId]?.locale;
if (exhaustedLocale) {
stats.failedLanguages.add(exhaustedLocale);
}

pendingJobs.delete(jobId);
return { jobId, status: 'failed' };
}
Expand Down
47 changes: 44 additions & 3 deletions src/utils/translation-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
ProjectConfig,
} from '../types/index.js';
import { TranslationBatch } from './translation-processor.js';
import { findMissingPoTranslations, createUniqueKey, PLURAL_PREFIX } from './po-utils.js';
import { findMissingPoTranslations, createUniqueKey, PLURAL_PREFIX, PLURAL_SUFFIX_REGEX } from './po-utils.js';
import { filterKeys, RemovedKey } from './ignore-keys.js';

const POT_EXTENSION = '.pot';
Expand Down Expand Up @@ -425,8 +425,49 @@ export function batchKeysWithMissing(
const allKeys = Object.entries(data.keys);
const chunkedKeys: Array<Array<[string, Record<string, TranslationValue>]>> = [];

for (let i = 0; i < allKeys.length; i += MAX_BATCH_SIZE) {
chunkedKeys.push(allKeys.slice(i, i + MAX_BATCH_SIZE));
// Chunk on plural-group boundaries. A form that lands in a different batch
// than its siblings arrives as a partial payload, which leaves the entry
// half-written and, for a fuzzy entry, never clears its flag.
// Only gettext catalogs use the __plural_N suffix to mean "another form of
// the same entry"; elsewhere it is an ordinary key name. Grouped by base key
// rather than adjacency, since merging several locales can interleave a
// family's forms. A family larger than the cap is still split: the cap is a
// request-size limit, not something to bypass.
const isPoFormat = ['po', 'pot'].includes(sourceFile.format.toLowerCase());
const groups: Array<Array<[string, Record<string, TranslationValue>]>> = [];

if (isPoFormat) {
const groupsByBase = new Map<string, Array<[string, Record<string, TranslationValue>]>>();
for (const entry of allKeys) {
const base = entry[0].replace(PLURAL_SUFFIX_REGEX, '');
const group = groupsByBase.get(base);
if (group) {
group.push(entry);
} else {
groupsByBase.set(base, [entry]);
}
}
for (const group of groupsByBase.values()) {
for (let i = 0; i < group.length; i += MAX_BATCH_SIZE) {
groups.push(group.slice(i, i + MAX_BATCH_SIZE));
}
}
} else {
for (const entry of allKeys) {
groups.push([entry]);
}
}

let chunk: Array<[string, Record<string, TranslationValue>]> = [];
for (const group of groups) {
if (chunk.length > 0 && chunk.length + group.length > MAX_BATCH_SIZE) {
chunkedKeys.push(chunk);
chunk = [];
}
chunk.push(...group);
}
if (chunk.length > 0) {
chunkedKeys.push(chunk);
}

for (const keyChunk of chunkedKeys) {
Expand Down
Loading
Loading