#!/usr/bin/env node // Version: 0.1.15 const { readdir, readFile, writeFile } = require('node:fs/promises'); const { existsSync, writeFileSync } = require('node:fs'); const path = require('node:path'); // Import shared utilities const { detectSrcRoot } = require('./lib/projectConfig'); const { getIgnorePatterns, isPlaceholderOnlyText } = require('./lib/ignorePatterns'); const { listLocales } = require('./lib/localeUtils'); const projectRoot = path.resolve(__dirname, '..'); const autoDir = path.resolve(projectRoot, 'resources', 'js', 'i18n', 'auto'); const srcRoot = detectSrcRoot(projectRoot); const ignorePatternsPath = path.resolve(projectRoot, 'scripts', 'i18n-ignore-patterns.json'); // Initialize ignore patterns from shared utility getIgnorePatterns(projectRoot); function shouldPersistIgnorePattern(normalized) { const value = String(normalized || '').trim(); if (!value) return false; const isLower = value === value.toLowerCase(); const isKeyLike = isLower && /^[a-z0-9_]+(\.[a-z0-9_]+)+$/.test(value); if (!isKeyLike) return true; const lastSegment = value.split('.').pop(); const tlds = new Set(['com', 'net', 'org', 'io', 'dev', 'app', 'co', 'edu', 'gov', 'info']); if (lastSegment && tlds.has(lastSegment)) { return true; } return false; } function addExactIgnorePattern(value) { if (!value) return; const normalized = String(value).replace(/\s+/g, ' ').trim(); if (!normalized) return; if (!shouldPersistIgnorePattern(normalized)) return; const patterns = getIgnorePatterns(projectRoot); if (!patterns.exact) { patterns.exact = []; } if (patterns.exact.includes(normalized)) { return; } patterns.exact.push(normalized); try { writeFileSync(ignorePatternsPath, JSON.stringify(patterns, null, 2) + '\n', 'utf8'); } catch { // ignore write errors to avoid breaking the main flow } } async function collectJsonFiles(dir, out) { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { await collectJsonFiles(entryPath, out); } else if (entry.isFile() && entry.name.endsWith('.json')) { out.push(entryPath); } } } async function readJsonSafe(filePath) { try { const raw = await readFile(filePath, 'utf8'); return JSON.parse(raw); } catch (err) { console.error(`[fix-untranslated] Failed to read/parse JSON: ${filePath}`); console.error(err?.message || err); return null; } } async function collectSourceFiles(dir, out) { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (['node_modules', 'vendor', '.git', 'storage', 'bootstrap', 'public'].includes(entry.name)) { continue; } await collectSourceFiles(entryPath, out); } else if (entry.isFile()) { if (/\.(tsx|ts|jsx|js)$/i.test(entry.name)) { out.push(entryPath); } } } } let cachedUsageIndex = null; async function buildUsageIndex() { const index = Object.create(null); if (!existsSync(srcRoot)) return index; const files = []; await collectSourceFiles(srcRoot, files); console.log(`[fix-untranslated] Scanning ${files.length} source files for key usage...`); for (const file of files) { const rel = path.relative(projectRoot, file); const code = await readFile(file, 'utf8'); const lines = code.split(/\r?\n/); const regex = /(?:^|[^a-zA-Z0-9_$])\$?t\s*\(\s*(['"`])([A-Za-z0-9_\.\-]+)\1\s*(?:,|\))/g; for (let i = 0; i < lines.length; i += 1) { const lineText = lines[i]; let match; while ((match = regex.exec(lineText)) !== null) { const key = match[2]; if (!index[key]) { index[key] = []; } index[key].push({ file: rel, line: i + 1 }); } } } console.log('[fix-untranslated] Finished building source usage index.'); return index; } async function indexKeyUsage(fullKey) { if (!fullKey) return []; if (!cachedUsageIndex) { cachedUsageIndex = await buildUsageIndex(); } return cachedUsageIndex[fullKey] || []; } function isNonTranslatableExample(text) { const trimmed = String(text || '').trim(); if (!trimmed) return false; const normalized = trimmed.replace(/\s+/g, ' '); const ignore = getIgnorePatterns(); if (ignore && Array.isArray(ignore.exact) && ignore.exact.includes(normalized)) { return true; } if (ignore && Array.isArray(ignore.exactInsensitive)) { const lowerNorm = normalized.toLowerCase(); for (const v of ignore.exactInsensitive) { if (String(v).toLowerCase() === lowerNorm) { return true; } } } if (ignore && Array.isArray(ignore.contains)) { for (const part of ignore.contains) { if (part && normalized.includes(String(part))) { return true; } } } if (isPlaceholderOnlyText(normalized)) { addExactIgnorePattern(normalized); return true; } if (!/\s/.test(normalized) && normalized.includes('.') && /^[A-Za-z0-9.-]+$/.test(normalized)) { addExactIgnorePattern(normalized); return true; } if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized)) { addExactIgnorePattern(normalized); return true; } // Query-string or URL-fragment-like segments such as "?duration=", "?lang=en" if (!/\s/.test(normalized) && /[?&]/.test(normalized) && /=/.test(normalized)) { addExactIgnorePattern(normalized); return true; } if (!/\s/.test(normalized) && /[:\[\]]/.test(normalized) && /^[A-Za-z0-9:._\-\[\]]+$/.test(normalized)) { addExactIgnorePattern(normalized); return true; } if (/[{};]/.test(normalized) && /\b(const|let|var|function|return|if|else|for|while|class|async|await)\b/.test(normalized)) { addExactIgnorePattern(normalized); return true; } // Domain + optional port and qualifier, e.g. "imap.gmail.com:993 (SSL)" if (/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}(:\d+)?(\s*\([A-Za-z0-9\s]+\))?$/.test(normalized)) { addExactIgnorePattern(normalized); return true; } if (/\{\{\s*[^}]+\s*\}\}/.test(normalized) && (/[?:]/.test(normalized) || /\|\|/.test(normalized) || /&&/.test(normalized) || /\.length\b/.test(normalized))) { addExactIgnorePattern(normalized); return true; } // Strings that are overwhelmingly CSS/utility classes plus placeholders { const words = normalized.split(/\s+/); const nonPlaceholderWords = words.filter((w) => !/^\{[^}]+\}$/.test(w)); if (nonPlaceholderWords.length > 0) { const cssishWords = nonPlaceholderWords.filter( (w) => /[-:]/.test(w) && /^[A-Za-z0-9:._\-\[\]]+$/.test(w), ); if ( cssishWords.length >= 2 && cssishWords.length >= nonPlaceholderWords.length - 1 ) { addExactIgnorePattern(normalized); return true; } if ( nonPlaceholderWords.length === 1 && cssishWords.length === 1 && nonPlaceholderWords[0].includes('-') ) { addExactIgnorePattern(normalized); return true; } } } // Analytics / object-literal style code snippets (e.g. gtag(... { 'send_to': ... }); ) if ( normalized.includes('gtag(') || (/[{}]/.test(normalized) && /['"][^'"]+['"]\s*:/.test(normalized)) ) { addExactIgnorePattern(normalized); return true; } // Strings that clearly look like examples (example.com, example-bucket, etc.) if (/\bexample\b/i.test(normalized)) { addExactIgnorePattern(normalized); return true; } // Obvious filesystem paths (Unix/Windows) that are not URLs if ( (/^[\\/][^\s]+$/.test(normalized)) || // /var/mail, \\server\\share (/^[A-Za-z]:[\\/][^\s]*$/.test(normalized)) // C:\path\to\file ) { addExactIgnorePattern(normalized); return true; } // Single-character strings are almost never meaningful UI copy if (normalized.length === 1) { addExactIgnorePattern(normalized); return true; } // Obvious URLs if (/^https?:\/\//i.test(normalized) || /^www\./i.test(normalized)) { addExactIgnorePattern(normalized); return true; } // Sample email addresses on example/acme domains const emailMatch = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized); if (emailMatch) { const lower = normalized.toLowerCase(); if (lower.includes('example.') || lower.includes('acme.')) { addExactIgnorePattern(normalized); return true; } } // Placeholder IDs / tokens like abcd1234 (no spaces, mixed letters+digits) const noSpace = !/\s/.test(normalized); const hasLetter = /[A-Za-z]/.test(normalized); const hasDigit = /\d/.test(normalized); if (noSpace && hasLetter && hasDigit && normalized.length >= 6 && normalized.length <= 64) { addExactIgnorePattern(normalized); return true; } // Well-known dummy names / brands and protocol-like tokens const lowerNorm = normalized.toLowerCase(); if ( lowerNorm === 'jane doe' || lowerNorm === 'john doe' || lowerNorm === 'acme corporation' || lowerNorm === 'acme inc' || lowerNorm === 'acme corp' || lowerNorm === 'acme company' || lowerNorm === 'venmail' || /\bacme\b/.test(lowerNorm) || /\bvenmail\b/.test(lowerNorm) || /\bamazon s3\b/.test(lowerNorm) || ['webdav', 'imap', 'smtp', 'pop3', 'ftp', 'sftp'].includes(lowerNorm) ) { addExactIgnorePattern(normalized); return true; } const alphaNum = normalized.replace(/[^A-Za-z0-9]/g, ''); if (alphaNum && alphaNum.length <= 4 && alphaNum.toUpperCase() === alphaNum) { addExactIgnorePattern(normalized); return true; } if (/\d{2,}/.test(normalized)) { const lower = normalized.toLowerCase(); if (lower.includes('main street') && lower.includes('city') && lower.includes('state')) { addExactIgnorePattern(normalized); return true; } } // Heuristic: single "word" strings that look like random codes/abbreviations // rather than natural language. This helps auto-ignore things like internal // identifiers that happen to be present in locale files. const wordMatch = normalized.match(/^[A-Za-z]{3,8}$/); if (wordMatch) { const word = wordMatch[0]; const lower = word.toLowerCase(); const vowels = lower.replace(/[^aeiouy]/g, '').length; const consonants = lower.replace(/[^bcdfghjklmnpqrstvwxyz]/g, '').length; // Ignore words that have no vowels at all (e.g. "crm", "cfg"), since // these are overwhelmingly likely to be codes or abbreviations. if (vowels === 0 && consonants >= 2) { addExactIgnorePattern(normalized); return true; } // Ignore short "dense" consonant clusters like cccv/cccv etc (3 or more // consonants in a row) for words up to length 6; these are very unlikely // to be meaningful UI copy in English and are often internal codes. if (word.length <= 6 && /[bcdfghjklmnpqrstvwxyz]{3,}/i.test(word)) { addExactIgnorePattern(normalized); return true; } } return false; } function walkCompare(enObj, locObj, pathSegments, issues, locale, options) { if (!enObj || typeof enObj !== 'object' || Array.isArray(enObj)) return; if (!locObj || typeof locObj !== 'object' || Array.isArray(locObj)) return; for (const [key, enVal] of Object.entries(enObj)) { const nextPath = pathSegments.concat(key); const locVal = Object.prototype.hasOwnProperty.call(locObj, key) ? locObj[key] : undefined; if (typeof enVal === 'string' && typeof locVal === 'string') { const enTrimmed = String(enVal || '').trim(); if (!enTrimmed || !/[A-Za-z]/.test(enTrimmed) || isNonTranslatableExample(enTrimmed)) { continue; } if (locVal === enVal) { const keyPath = nextPath.join('.'); // Generic untranslated issue (locale string identical to English) issues.push({ locale, keyPath, english: enVal, current: locVal, issueType: 'untranslated', }); } // Optional: style hints for French button labels if ( locale === 'fr' && options && options.conciseMap && pathSegments.includes('button') && options.conciseMap[locVal] ) { const keyPath = nextPath.join('.'); issues.push({ locale, keyPath, english: enVal, current: locVal, suggested: options.conciseMap[locVal], issueType: 'style', }); } } else if ( enVal && typeof enVal === 'object' && !Array.isArray(enVal) && locVal && typeof locVal === 'object' && !Array.isArray(locVal) ) { walkCompare(enVal, locVal, nextPath, issues, locale, options); } } } async function main() { if (!existsSync(autoDir)) { console.error('[fix-untranslated] i18n auto directory not found:', autoDir); process.exit(1); } let hadLocaleReadErrors = false; const locales = await listLocales(autoDir, { includeJsonFiles: false }); if (!locales.includes('en')) { console.error('[fix-untranslated] No "en" locale found under', autoDir); process.exit(1); } const issues = []; const conciseFrMap = { "Mettre à jour l'utilisateur": "Modifier", "Mettre à jour l'invitation": "Modifier", "Mettre à jour le mot de passe": "Modifier", "Mettre à jour l'abonnement": "Modifier", "Mettre à jour": "Modifier", "Supprimer l'utilisateur": "Supprimer", "Supprimer le compte": "Supprimer", "Supprimer le jeton": "Supprimer", "Supprimer l'arrière-plan": "Supprimer", "Enregistrer l'abonnement": "Enregistrer", "Enregistrer le fournisseur": "Enregistrer", "Enregistrer les paramètres API": "Enregistrer", "Enregistrer les modifications": "Enregistrer", "Ajouter un libellé": "Ajouter", "Ajouter un fournisseur": "Ajouter", "Ajouter un client": "Ajouter", "Ajouter des fonds": "Ajouter", "Ajouter une note": "Ajouter", "Créer un nouveau libellé": "Créer", "Créer une prospection": "Créer", "Créer un profil": "Créer", "Créer une tâche": "Créer", "Créer une équipe": "Créer", "Copier/Partager l'URL d'invitation": "Copier le lien", "Personnaliser le message d'invitation": "Personnaliser", "Envoyer le mot de passe à l'utilisateur": "Envoyer", "Envoyer le mot de passe": "Envoyer", "Importer des utilisateurs existants": "Importer", "Démarrer l'import": "Démarrer", "Voir les détails": "Détails", "Retour aux contacts": "Retour", "Alimenter le portefeuille": "Alimenter", "Gérer les valeurs par défaut partenaires": "Gérer", "Voir tous les clients": "Voir tout", "Recommander un partenaire": "Recommander", "Recommander votre premier partenaire": "Recommander", "Obtenir le jeton FCM": "Obtenir", "Rafraîchir le jeton": "Rafraîchir", "Rafraîchir la page": "Rafraîchir", "Revenir aux réunions": "Retour", "Rejoindre la réunion": "Rejoindre", "Mettre fin à l'abonnement": "Résilier", "Annuler l'abonnement": "Annuler", "Correction automatique": "Auto-corriger", "Reprogrammer": "Modifier", "Planifier une réunion": "Planifier", "Ajouter un e‑mail de relance": "Ajouter", "Copier l'invitation": "Copier", "Copier l'e‑mail": "Copier", "Copier le lien": "Copier", }; for (const locale of locales) { if (locale === 'en') continue; const locRoot = path.resolve(autoDir, locale); const enRoot = path.resolve(autoDir, 'en'); const locFiles = []; await collectJsonFiles(locRoot, locFiles); for (const locFile of locFiles) { const rel = path.relative(locRoot, locFile); const enFile = path.resolve(enRoot, rel); if (!existsSync(enFile)) continue; const locJson = await readJsonSafe(locFile); const enJson = await readJsonSafe(enFile); if (!locJson || !enJson) { hadLocaleReadErrors = true; continue; } const fileIssues = []; walkCompare( enJson, locJson, [], fileIssues, locale, locale === 'fr' ? { conciseMap: conciseFrMap } : {}, ); for (const issue of fileIssues) { issue.localeFile = path.relative(autoDir, locFile); issues.push(issue); } } } if (issues.length === 0) { if (hadLocaleReadErrors) { console.error('[fix-untranslated] Aborting: one or more locale JSON files could not be parsed.'); process.exit(1); } console.log('[fix-untranslated] No untranslated or style issues detected.'); process.exit(0); } if (hadLocaleReadErrors) { console.error('[fix-untranslated] Aborting: one or more locale JSON files could not be parsed.'); process.exit(1); } function groupIssuesByFile(issuesSubset) { const byFile = new Map(); for (const issue of issuesSubset) { const key = `${issue.locale}::${issue.localeFile || ''}`; let entry = byFile.get(key); if (!entry) { entry = { locale: issue.locale, localeFile: issue.localeFile, issues: [], }; byFile.set(key, entry); } const item = { keyPath: issue.keyPath, english: issue.english, current: issue.current, }; if (issue.issueType === 'style' && issue.suggested) { item.suggested = issue.suggested; } entry.issues.push(item); } return Array.from(byFile.values()).sort((a, b) => { if (a.locale === b.locale) { return String(a.localeFile || '').localeCompare(String(b.localeFile || '')); } return String(a.locale).localeCompare(String(b.locale)); }); } const untranslatedIssues = issues.filter((i) => i.issueType === 'untranslated'); const styleIssues = issues.filter((i) => i.issueType === 'style'); const generatedAt = new Date().toISOString(); const combinedReportPath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-report.json'); const combinedReport = { generatedAt, issues, }; await writeFile(combinedReportPath, JSON.stringify(combinedReport, null, 2) + '\n', 'utf8'); console.log('[fix-untranslated] Combined report written to', path.relative(projectRoot, combinedReportPath)); console.log(`[fix-untranslated] Total issues: ${issues.length}`); if (untranslatedIssues.length > 0) { const files = groupIssuesByFile(untranslatedIssues); const untranslatedPath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-untranslated.json'); const report = { issueType: 'untranslated', generatedAt, files, }; await writeFile(untranslatedPath, JSON.stringify(report, null, 2) + '\n', 'utf8'); console.log('[fix-untranslated] Untranslated report written to', path.relative(projectRoot, untranslatedPath)); console.log(` Files: ${files.length}, Issues: ${untranslatedIssues.length}`); // Also write a compact untranslated report that only contains, for each // locale/localeFile pair, the source files and line ranges where // untranslated keys are used. This is optimized for GPT-style tools that // only need to know *where* to look in the source, not per-key details. const compactByFile = new Map(); // Pre-seed entries for each locale/localeFile pair. for (const issue of untranslatedIssues) { const key = `${issue.locale}::${issue.localeFile || ''}`; if (!compactByFile.has(key)) { compactByFile.set(key, { locale: issue.locale, localeFile: issue.localeFile, ranges: new Map(), // sourceFile -> { file, lineStart, lineEnd } }); } } // For each untranslated key, look up where it is used in source and merge // all usages into line ranges per source file. for (const issue of untranslatedIssues) { const key = `${issue.locale}::${issue.localeFile || ''}`; const entry = compactByFile.get(key); if (!entry) continue; const usage = await indexKeyUsage(issue.keyPath); if (!usage || !usage.length) continue; for (const u of usage) { const fileKey = u.file; let range = entry.ranges.get(fileKey); if (!range) { range = { file: fileKey, lineStart: u.line, lineEnd: u.line }; } else { if (u.line < range.lineStart) range.lineStart = u.line; if (u.line > range.lineEnd) range.lineEnd = u.line; } entry.ranges.set(fileKey, range); } } const compactFiles = Array.from(compactByFile.values()) .map((entry) => ({ locale: entry.locale, localeFile: entry.localeFile, issues: Array.from(entry.ranges.values()), })) .filter((entry) => entry.issues.length > 0) .sort((a, b) => { if (a.locale === b.locale) { return String(a.localeFile || '').localeCompare(String(b.localeFile || '')); } return String(a.locale).localeCompare(String(b.locale)); }); const compactPath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-compact.json'); const compactReport = { issueType: 'untranslated', generatedAt, files: compactFiles, }; await writeFile(compactPath, JSON.stringify(compactReport, null, 2) + '\n', 'utf8'); console.log('[fix-untranslated] Compact untranslated report written to', path.relative(projectRoot, compactPath)); } if (styleIssues.length > 0) { const files = groupIssuesByFile(styleIssues); const stylePath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-style.json'); const report = { issueType: 'style', generatedAt, files, }; await writeFile(stylePath, JSON.stringify(report, null, 2) + '\n', 'utf8'); console.log('[fix-untranslated] Style report written to', path.relative(projectRoot, stylePath)); console.log(` Files: ${files.length}, Issues: ${styleIssues.length}`); } // Write a small standalone AI instructions text file instead of embedding // bulky prompts inside the JSON reports. This keeps the JSON lean while // still giving consumers a ready-to-use prompt. const instructionsPath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-ai-instructions.txt'); const instructions = [ 'You are given one JSON report at a time describing i18n translation issues and to apply fixes not suggestions', '', 'Input JSON structure:', '- issueType: "untranslated" or "style".', '- files: array of { locale, localeFile, issues }.', '- each issues[] item has: keyPath, english, current, and for style issues an optional suggested value.', '', 'Processing rules:', '- Treat each element in files[] as a separate batch that corresponds to a single locale JSON file (localeFile).', '- For every issue in issues[], you must propose a final translation for that locale.', '- For issueType="untranslated": translate english into the target locale language.', '- For issueType="style": refine current (or suggested, if present) into a concise, UI-appropriate label in the same locale.', '- Always preserve placeholders exactly (e.g. {name}, {value1}, {{ variable }}).', '- Do not change brand names (e.g. Venmail) or protocol names (e.g. SMTP, IMAP, WebDAV).', '', 'Your goal is to help improve translations in-place, in small batches per locale file, based on this report applying the suggestions to relevant files as necessary.', ].join('\n'); await writeFile(instructionsPath, instructions + '\n', 'utf8'); console.log('[fix-untranslated] AI instructions written to', path.relative(projectRoot, instructionsPath)); } main().catch((err) => { console.error('[fix-untranslated] Failed:', err && err.message ? err.message : err); process.exit(1); });