feat(i18n): fix translation QA issues and add automation (#16756)

## Summary

This PR fixes translation QA issues and adds automation to prevent
future issues.

### Translation Fixes
- Fixed **escaped Unicode sequences** in translations (e.g.,
`\u62db\u5f85` → `招待`)
- Removed **corrupted control characters** from .po files (null bytes,
invalid characters)
- Fixed **missing/incorrect placeholders** in various languages
- Deleted **35 problematic translations** via Crowdin API that had
variable mismatches

### New Scripts (in `packages/twenty-utils/`)
- `fix-crowdin-translations.ts` - Auto-fixes encoding issues and syncs
to Crowdin
- `fix-qa-issues.ts` - Fixes specific QA issues via Crowdin API
- `translation-qa-report.ts` - Generates weekly QA report from Crowdin
API

### New Workflow
- `i18n-qa-report.yaml` - Weekly workflow that creates a PR with
translation QA issues for review

### Other Changes
- Moved GitHub Actions from `.github/workflows/actions/` to
`.github/actions/`
- Fixed `date-utils.ts` to avoid nested `t` macros in plural expressions
(root cause of confusing placeholders)

### QA Status After Fixes
| Category | Count | Status |
|----------|-------|--------|
| variables | 0  | Fixed |
| tags | 1 | Minor |
| empty | 0  | Fixed |
| spaces | 127 | Low priority |
| numbers | 246 | Locale-specific |
| special_symbols | 268 | Locale-specific |
This commit is contained in:
Félix Malfait
2025-12-22 17:30:46 +01:00
committed by GitHub
parent ede261abf4
commit e6491d6a80
114 changed files with 1668 additions and 976 deletions
@@ -0,0 +1,251 @@
/**
* Script to fix encoding issues in Crowdin translations
*
* This script:
* 1. Fetches translations from Crowdin API that have escaped Unicode sequences
* 2. Deletes the corrupted translations
* 3. Adds corrected translations (or activates existing corrected suggestions)
*
* Usage:
* CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
*
* The token can be obtained from: https://twenty.crowdin.com/u/settings#api-key
*/
const CROWDIN_BASE_URL = 'https://twenty.api.crowdin.com/api/v2';
const CROWDIN_PROJECT_ID = 1;
type CrowdinTranslation = {
stringId: number;
translationId: number;
text: string;
};
async function getToken(): Promise<string> {
const token = process.env.CROWDIN_PERSONAL_TOKEN;
if (!token) {
console.error(
'Error: CROWDIN_PERSONAL_TOKEN environment variable not set',
);
console.error(
'Get your token from: https://twenty.crowdin.com/u/settings#api-key',
);
process.exit(1);
}
return token;
}
async function crowdinRequest<T>(
endpoint: string,
token: string,
options: RequestInit = {},
): Promise<T | null> {
const url = `${CROWDIN_BASE_URL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
if (response.status === 404) return null;
const text = await response.text();
throw new Error(`Crowdin API error: ${response.status} ${text}`);
}
const text = await response.text();
if (!text) return null;
return JSON.parse(text) as T;
}
async function getProjectLanguages(token: string): Promise<string[]> {
type ProjectResponse = {
data: {
targetLanguageIds: string[];
};
};
const response = await crowdinRequest<ProjectResponse>(
`/projects/${CROWDIN_PROJECT_ID}`,
token,
);
return response?.data.targetLanguageIds || [];
}
async function getTranslationsForLanguage(
token: string,
languageId: string,
): Promise<CrowdinTranslation[]> {
const translations: CrowdinTranslation[] = [];
let offset = 0;
const limit = 500;
while (true) {
type TranslationsResponse = {
data: Array<{
data: {
stringId: number;
translationId: number;
text: string;
};
}>;
};
const response = await crowdinRequest<TranslationsResponse>(
`/projects/${CROWDIN_PROJECT_ID}/languages/${languageId}/translations?limit=${limit}&offset=${offset}`,
token,
);
if (!response || response.data.length === 0) break;
for (const item of response.data) {
translations.push({
stringId: item.data.stringId,
translationId: item.data.translationId,
text: item.data.text,
});
}
if (response.data.length < limit) break;
offset += limit;
}
return translations;
}
async function deleteTranslation(
token: string,
translationId: number,
): Promise<boolean> {
const result = await crowdinRequest(
`/projects/${CROWDIN_PROJECT_ID}/translations/${translationId}`,
token,
{ method: 'DELETE' },
);
return result === null; // null means success (empty response)
}
async function addTranslation(
token: string,
stringId: number,
languageId: string,
text: string,
): Promise<boolean> {
const url = `${CROWDIN_BASE_URL}/projects/${CROWDIN_PROJECT_ID}/translations`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ stringId, languageId, text }),
});
// Success if added OR if identical already exists (meaning correct version is now active)
if (response.ok) return true;
const data = await response.json();
const errorMsg = data?.errors?.[0]?.error?.errors?.[0]?.message || '';
// "Identical translation already saved" means the correct version was already a suggestion
// and is now the active translation after we deleted the corrupted one
if (errorMsg.includes('identical')) return true;
throw new Error(`Failed to add translation: ${JSON.stringify(data)}`);
}
function hasEscapedUnicode(text: string): boolean {
// Match literal \uXXXX sequences in the text
// The text comes JSON-decoded, so \\u in the original becomes \u in the string
return /\\u[0-9a-fA-F]{4}/.test(text);
}
function fixEscapedUnicode(text: string): string {
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex) => {
try {
return String.fromCharCode(parseInt(hex, 16));
} catch {
return _match;
}
});
}
async function main() {
const token = await getToken();
console.log('Fetching project languages...');
const languages = await getProjectLanguages(token);
console.log(`Languages: ${languages.length}`);
let totalFixed = 0;
for (const languageId of languages) {
process.stdout.write(`Checking ${languageId}...`);
try {
const translations = await getTranslationsForLanguage(token, languageId);
// Find translations with escaped Unicode
const toFix = translations.filter((t) => hasEscapedUnicode(t.text));
if (toFix.length === 0) {
console.log(` ${translations.length} translations, 0 issues`);
continue;
}
console.log(
` ${translations.length} translations, ${toFix.length} to fix`,
);
let fixedInLang = 0;
// Fix each translation
for (const translation of toFix) {
const fixedText = fixEscapedUnicode(translation.text);
try {
// Delete the corrupted translation
await deleteTranslation(token, translation.translationId);
// Add the corrected translation (or let existing suggestion become active)
await addTranslation(
token,
translation.stringId,
languageId,
fixedText,
);
fixedInLang++;
totalFixed++;
process.stdout.write('.');
} catch (error) {
console.log(
`\n Failed to fix string ${translation.stringId}: ${error}`,
);
}
}
if (fixedInLang > 0) {
console.log(` ✓ Fixed ${fixedInLang}`);
}
} catch (error) {
console.log(` error: ${error}`);
}
}
console.log(`\nDone! Fixed ${totalFixed} translations in Crowdin.`);
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
+428
View File
@@ -0,0 +1,428 @@
/**
* Script to fix QA issues detected by Crowdin
*
* Fixes:
* - Variables mismatch (translated placeholder names)
* - Empty translations
* - Tags mismatch
*
* Usage:
* CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-qa-issues.ts
*/
const CROWDIN_BASE_URL = 'https://twenty.api.crowdin.com/api/v2';
const CROWDIN_PROJECT_ID = 1;
type QACheck = {
stringId: number;
languageId: string;
category: string;
validation: string;
text: string;
};
async function getToken(): Promise<string> {
const token = process.env.CROWDIN_PERSONAL_TOKEN;
if (!token) {
console.error('Error: CROWDIN_PERSONAL_TOKEN not set');
process.exit(1);
}
return token;
}
async function crowdinGet<T>(endpoint: string, token: string): Promise<T> {
const response = await fetch(`${CROWDIN_BASE_URL}${endpoint}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function getSourceString(
token: string,
stringId: number,
): Promise<string> {
type Response = { data: { text: string } };
const data = await crowdinGet<Response>(
`/projects/${CROWDIN_PROJECT_ID}/strings/${stringId}`,
token,
);
return data.data.text;
}
async function getTranslation(
token: string,
stringId: number,
languageId: string,
): Promise<{ translationId: number; text: string } | null> {
type Response = {
data: Array<{ data: { translationId: number; text: string } }>;
};
const data = await crowdinGet<Response>(
`/projects/${CROWDIN_PROJECT_ID}/languages/${languageId}/translations?stringIds=${stringId}`,
token,
);
if (data.data.length === 0) return null;
return {
translationId: data.data[0].data.translationId,
text: data.data[0].data.text,
};
}
async function deleteTranslation(
token: string,
translationId: number,
): Promise<void> {
const response = await fetch(
`${CROWDIN_BASE_URL}/projects/${CROWDIN_PROJECT_ID}/translations/${translationId}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
},
);
if (!response.ok && response.status !== 404) {
throw new Error(`Delete failed: ${response.status}`);
}
}
async function addTranslation(
token: string,
stringId: number,
languageId: string,
text: string,
): Promise<void> {
const response = await fetch(
`${CROWDIN_BASE_URL}/projects/${CROWDIN_PROJECT_ID}/translations`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ stringId, languageId, text }),
},
);
// OK if added or if identical exists
if (!response.ok) {
const data = await response.json();
const msg = JSON.stringify(data);
if (!msg.includes('identical')) {
throw new Error(`Add failed: ${msg}`);
}
}
}
// Extract placeholder names from text (e.g., {days}, {count}, ${price})
function extractPlaceholders(text: string): string[] {
const matches = text.match(/\$?\{[a-zA-Z_][a-zA-Z0-9_]*\}/g) || [];
return [...new Set(matches)];
}
// Fix translated placeholder names back to source names
function fixPlaceholderNames(
sourceText: string,
translationText: string,
): string | null {
const sourcePlaceholders = extractPlaceholders(sourceText);
const translationPlaceholders = extractPlaceholders(translationText);
if (sourcePlaceholders.length === 0) return null;
// Check if any placeholders were translated
const missingInTranslation = sourcePlaceholders.filter(
(p) => !translationPlaceholders.includes(p),
);
if (missingInTranslation.length === 0) return null;
// Try to find translated versions and replace them
let fixedText = translationText;
for (const sourcePlaceholder of missingInTranslation) {
// Extract the name from source placeholder
const sourceNameMatch = sourcePlaceholder.match(/\$?\{([^}]+)\}/);
if (!sourceNameMatch) continue;
const sourceName = sourceNameMatch[1];
// Find potential translations of this placeholder
// Common patterns: {days} -> {jours}, {dae}, {dni}, {días}, etc.
for (const transPlaceholder of translationPlaceholders) {
const transNameMatch = transPlaceholder.match(/\$?\{([^}]+)\}/);
if (!transNameMatch) continue;
const transName = transNameMatch[1];
// If trans placeholder is not in source, it might be a translated version
if (
!sourcePlaceholders.includes(transPlaceholder) &&
transName !== sourceName
) {
// Check if this looks like a translation of the source name
// (same position in ICU structure, similar pattern)
// For ICU plural messages, check if the placeholder appears in same position
const sourcePattern = new RegExp(
`\\{${sourceName}\\}`,
'g',
);
const transPattern = new RegExp(`\\{${transName}\\}`, 'g');
const sourceMatches = sourceText.match(sourcePattern)?.length || 0;
const transMatches = translationText.match(transPattern)?.length || 0;
if (sourceMatches > 0 && transMatches > 0 && sourceMatches === transMatches) {
// Replace translated placeholder with source placeholder
fixedText = fixedText.replace(
new RegExp(`\\{${transName}\\}`, 'g'),
`{${sourceName}}`,
);
console.log(` Replacing {${transName}} -> {${sourceName}}`);
}
}
}
// Handle $ prefix for currency
if (sourcePlaceholder.startsWith('$')) {
const withoutDollar = sourcePlaceholder.slice(1);
if (translationText.includes(withoutDollar)) {
fixedText = fixedText.replace(withoutDollar, sourcePlaceholder);
console.log(` Adding $ prefix: ${withoutDollar} -> ${sourcePlaceholder}`);
}
}
}
// Return null if nothing changed
if (fixedText === translationText) return null;
return fixedText;
}
async function fetchQAChecks(
token: string,
category: string,
): Promise<QACheck[]> {
const checks: QACheck[] = [];
let offset = 0;
while (true) {
type Response = { data: Array<{ data: QACheck }> };
const data = await crowdinGet<Response>(
`/projects/${CROWDIN_PROJECT_ID}/qa-checks?limit=500&offset=${offset}&category=${category}`,
token,
);
if (data.data.length === 0) break;
for (const item of data.data) {
checks.push(item.data);
}
if (data.data.length < 500) break;
offset += 500;
}
return checks;
}
async function fixVariablesIssues(token: string): Promise<number> {
console.log('\n=== Fixing Variables Mismatch Issues ===\n');
const checks = await fetchQAChecks(token, 'variables');
console.log(`Found ${checks.length} variables issues\n`);
let fixed = 0;
for (const check of checks) {
console.log(
`String ${check.stringId} (${check.languageId}): ${check.text.slice(0, 80)}...`,
);
try {
const sourceText = await getSourceString(token, check.stringId);
const translation = await getTranslation(
token,
check.stringId,
check.languageId,
);
if (!translation) {
console.log(' -> No translation found, skipping\n');
continue;
}
const fixedText = fixPlaceholderNames(sourceText, translation.text);
if (!fixedText) {
console.log(' -> Could not auto-fix, manual review needed\n');
continue;
}
console.log(` Source: ${sourceText.slice(0, 60)}...`);
console.log(` Before: ${translation.text.slice(0, 60)}...`);
console.log(` After: ${fixedText.slice(0, 60)}...`);
// Delete old translation and add fixed one
await deleteTranslation(token, translation.translationId);
await addTranslation(token, check.stringId, check.languageId, fixedText);
console.log(' -> Fixed!\n');
fixed++;
} catch (error) {
console.log(` -> Error: ${error}\n`);
}
}
return fixed;
}
async function fixEmptyTranslations(token: string): Promise<number> {
console.log('\n=== Fixing Empty Translation Issues ===\n');
const checks = await fetchQAChecks(token, 'empty');
console.log(`Found ${checks.length} empty translation issues\n`);
// Empty translations should be deleted so they fall back to source
let fixed = 0;
for (const check of checks) {
console.log(`String ${check.stringId} (${check.languageId})`);
try {
const translation = await getTranslation(
token,
check.stringId,
check.languageId,
);
if (!translation) {
console.log(' -> No translation found\n');
continue;
}
if (translation.text.trim() === '') {
await deleteTranslation(token, translation.translationId);
console.log(' -> Deleted empty translation\n');
fixed++;
}
} catch (error) {
console.log(` -> Error: ${error}\n`);
}
}
return fixed;
}
async function fixTagsIssues(token: string): Promise<number> {
console.log('\n=== Fixing Tags Mismatch Issues ===\n');
const checks = await fetchQAChecks(token, 'tags');
console.log(`Found ${checks.length} tags issues\n`);
let fixed = 0;
for (const check of checks) {
console.log(`String ${check.stringId} (${check.languageId}): ${check.text.slice(0, 60)}...`);
try {
const sourceText = await getSourceString(token, check.stringId);
const translation = await getTranslation(
token,
check.stringId,
check.languageId,
);
if (!translation) {
console.log(' -> No translation found\n');
continue;
}
// Extract tags from source and translation
const sourceTags = sourceText.match(/<\/?[a-zA-Z][^>]*>/g) || [];
const translationTags = translation.text.match(/<\/?[a-zA-Z][^>]*>/g) || [];
// If translation has extra tags not in source, remove them
if (check.text.includes('extra formatting tags')) {
let fixedText = translation.text;
for (const tag of translationTags) {
if (!sourceTags.includes(tag)) {
// Remove extra tag
fixedText = fixedText.replace(new RegExp(tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '');
}
}
// Clean up any double spaces
fixedText = fixedText.replace(/\s+/g, ' ').trim();
if (fixedText !== translation.text) {
console.log(` Source: ${sourceText.slice(0, 60)}`);
console.log(` Before: ${translation.text.slice(0, 60)}`);
console.log(` After: ${fixedText.slice(0, 60)}`);
await deleteTranslation(token, translation.translationId);
await addTranslation(token, check.stringId, check.languageId, fixedText);
console.log(' -> Fixed!\n');
fixed++;
} else {
console.log(' -> Could not auto-fix\n');
}
} else {
console.log(' -> Manual review needed\n');
}
} catch (error) {
console.log(` -> Error: ${error}\n`);
}
}
return fixed;
}
async function main() {
const token = await getToken();
console.log('Crowdin QA Issues Fixer\n');
console.log('This script will fix:');
console.log('- Variables mismatch (translated placeholder names)');
console.log('- Empty translations');
console.log('- Tags mismatch (extra formatting tags)\n');
let totalFixed = 0;
// Fix variables issues
totalFixed += await fixVariablesIssues(token);
// Fix empty translations
totalFixed += await fixEmptyTranslations(token);
// Fix tags issues
totalFixed += await fixTagsIssues(token);
console.log(`\n=== Done! Fixed ${totalFixed} issues ===`);
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
@@ -0,0 +1,245 @@
/**
* Script to fetch and display QA issues from Crowdin
*
* This script uses Crowdin's native QA checks API to:
* - Fetch all QA issues detected by Crowdin
* - Group and display them by category and language
* - Provide actionable information for fixing
*
* Usage:
* CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/translation-qa-report.ts
*
* The token can be obtained from: https://twenty.crowdin.com/u/settings#api-key
*/
import * as fs from 'fs';
import * as path from 'path';
const CROWDIN_BASE_URL = 'https://twenty.api.crowdin.com/api/v2';
const CROWDIN_PROJECT_ID = 1;
type QACheck = {
stringId: number;
languageId: string;
category: string;
categoryDescription: string;
validation: string;
validationDescription: string;
pluralId: number;
text: string;
};
async function getToken(): Promise<string> {
const token = process.env.CROWDIN_PERSONAL_TOKEN;
if (!token) {
console.error(
'Error: CROWDIN_PERSONAL_TOKEN environment variable not set',
);
console.error(
'Get your token from: https://twenty.crowdin.com/u/settings#api-key',
);
process.exit(1);
}
return token;
}
async function fetchAllQAChecks(token: string): Promise<QACheck[]> {
const allChecks: QACheck[] = [];
let offset = 0;
const limit = 500;
console.log('Fetching QA issues from Crowdin...');
while (true) {
const url = `${CROWDIN_BASE_URL}/projects/${CROWDIN_PROJECT_ID}/qa-checks?limit=${limit}&offset=${offset}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Crowdin API error: ${response.status}`);
}
type QAResponse = {
data: Array<{ data: QACheck }>;
};
const data = (await response.json()) as QAResponse;
if (data.data.length === 0) break;
for (const item of data.data) {
allChecks.push(item.data);
}
console.log(` Fetched ${allChecks.length} issues...`);
if (data.data.length < limit) break;
offset += limit;
}
return allChecks;
}
function generateReport(checks: QACheck[]): string {
// Group by category
const byCategory = new Map<string, QACheck[]>();
for (const check of checks) {
const key = check.category;
const existing = byCategory.get(key) || [];
existing.push(check);
byCategory.set(key, existing);
}
// Group by language within each category
let report = `# Crowdin QA Issues Report
Generated: ${new Date().toISOString()}
## Summary
- **Total QA Issues**: ${checks.length}
- **Categories**: ${byCategory.size}
| Category | Count | Description |
|----------|-------|-------------|
`;
for (const [category, categoryChecks] of byCategory) {
const desc = categoryChecks[0]?.categoryDescription || category;
report += `| ${category} | ${categoryChecks.length} | ${desc} |\n`;
}
report += `
## Issues by Category
`;
// Sort categories by count (most issues first), but put spellcheck last
const sortedCategories = Array.from(byCategory.entries()).sort((a, b) => {
if (a[0] === 'spellcheck') return 1;
if (b[0] === 'spellcheck') return -1;
return b[1].length - a[1].length;
});
for (const [category, categoryChecks] of sortedCategories) {
const desc = categoryChecks[0]?.categoryDescription || category;
// Group by language
const byLang = new Map<string, QACheck[]>();
for (const check of categoryChecks) {
const existing = byLang.get(check.languageId) || [];
existing.push(check);
byLang.set(check.languageId, existing);
}
report += `### ${desc} (${categoryChecks.length} issues)
`;
// Show top issues per language
const sortedLangs = Array.from(byLang.entries()).sort(
(a, b) => b[1].length - a[1].length,
);
for (const [lang, langChecks] of sortedLangs.slice(0, 10)) {
report += `**${lang}** (${langChecks.length} issues):\n`;
for (const check of langChecks.slice(0, 5)) {
const truncatedText =
check.text.length > 100 ? check.text.slice(0, 100) + '...' : check.text;
report += `- String #${check.stringId}: ${truncatedText}\n`;
}
if (langChecks.length > 5) {
report += `- ... and ${langChecks.length - 5} more\n`;
}
report += '\n';
}
if (sortedLangs.length > 10) {
report += `*... and ${sortedLangs.length - 10} more languages with issues*\n\n`;
}
}
report += `## How to Fix
### Variables Mismatch
These are the most critical - placeholders must match exactly:
- Check that all \`{placeholder}\` variables from source appear in translation
- Don't translate placeholder names (e.g., \`{days}\` should stay \`{days}\`, not \`{jours}\`)
- Include \`$\` for currency placeholders (e.g., \`\${price}\`)
### Punctuation/Special Characters
- Ensure ending punctuation matches (periods, question marks, etc.)
- Check parentheses and brackets are balanced
### Spellcheck
- Often false positives for technical terms - can usually be ignored
- Review for actual typos
---
**Fix in Crowdin**: https://twenty.crowdin.com/u/projects/1/all?filter=qa-issue
*To auto-fix encoding issues, run:*
\`\`\`
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
\`\`\`
`;
return report;
}
async function main() {
const token = await getToken();
const checks = await fetchAllQAChecks(token);
console.log(`\nTotal QA issues: ${checks.length}`);
// Group by category for summary
const byCategory = new Map<string, number>();
for (const check of checks) {
byCategory.set(check.category, (byCategory.get(check.category) || 0) + 1);
}
console.log('\nBy category:');
for (const [cat, count] of byCategory) {
console.log(` ${cat}: ${count}`);
}
// Generate report
const report = generateReport(checks);
const reportPath = path.join(process.cwd(), 'TRANSLATION_QA_REPORT.md');
fs.writeFileSync(reportPath, report, 'utf-8');
console.log(`\nReport written to: ${reportPath}`);
// Return non-zero if there are critical issues (not spellcheck)
const criticalIssues = checks.filter((c) => c.category !== 'spellcheck');
if (criticalIssues.length > 0) {
console.log(`\n⚠️ ${criticalIssues.length} critical issues found (excluding spellcheck)`);
process.exit(1);
}
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});