#!/usr/bin/env node /** * Builds es.json, it.json, pt.json i18n overrides from FR reference strings. * Keys: locale still matches EN but FR differs (+ 11 mandatory keys). */ import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import https from 'https' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const localesDir = path.join(__dirname, '../../locales') const outDir = __dirname function flat(obj, prefix = '') { const result = {} for (const [k, v] of Object.entries(obj)) { const key = prefix ? `${prefix}.${k}` : k if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(result, flat(v, key)) else result[key] = v } return result } const MANDATORY = [ 'ai.featureLocked', 'ai.quotaExceeded', 'profile.tab', 'about.tab', 'appearance.tab', 'billing.tab', 'usageMeter.featureChat', 'usageMeter.featureReformulate', 'usageMeter.featureBrainstormCreate', 'usageMeter.featureBrainstormExpand', 'usageMeter.featureBrainstormEnrich', ] const LANG_MAP = { es: 'es', it: 'it', pt: 'pt', nl: 'nl', pl: 'pl', ru: 'ru', ar: 'ar', } function worklistKeys(lang) { const en = flat(JSON.parse(fs.readFileSync(path.join(localesDir, 'en.json'), 'utf8'))) const loc = flat(JSON.parse(fs.readFileSync(path.join(localesDir, `${lang}.json`), 'utf8'))) const fr = flat(JSON.parse(fs.readFileSync(path.join(localesDir, 'fr.json'), 'utf8'))) const keys = Object.keys(en).filter((k) => loc[k] === en[k] && fr[k] && fr[k] !== en[k]) for (const k of MANDATORY) { if (fr[k] && !keys.includes(k)) keys.push(k) } return keys.sort() } /** Protect ICU-style placeholders during translation */ function shieldPlaceholders(text) { const tokens = [] const shielded = text.replace(/\{[^}]+\}/g, (m) => { const id = `__PH_${tokens.length}__` tokens.push(m) return id }) return { shielded, tokens } } function unshieldPlaceholders(text, tokens) { let out = text tokens.forEach((tok, i) => { out = out.replace(`__PH_${i}__`, tok) out = out.replace(`__ PH _ ${i} __`, tok) out = out.replace(new RegExp(`__\\s*PH\\s*_\\s*${i}\\s*__`, 'gi'), tok) }) return out } function myMemoryTranslate(text, targetLang, attempt = 0) { return new Promise((resolve, reject) => { const { shielded, tokens } = shieldPlaceholders(text) const q = encodeURIComponent(shielded.slice(0, 4500)) const url = `https://api.mymemory.translated.net/get?q=${q}&langpair=fr|${targetLang}` https .get(url, (res) => { let body = '' res.on('data', (c) => (body += c)) res.on('end', () => { try { const data = JSON.parse(body) if (data.responseStatus === 200 && data.responseData?.translatedText) { resolve(unshieldPlaceholders(data.responseData.translatedText, tokens)) } else if (attempt < 4) { setTimeout(() => { myMemoryTranslate(text, targetLang, attempt + 1) .then(resolve) .catch(reject) }, 1500 * (attempt + 1)) } else { reject(new Error(data.responseDetails || 'translate failed')) } } catch (e) { reject(e) } }) }) .on('error', reject) }) } async function translateFr(text, targetLang) { if (!text || !text.trim()) return text return myMemoryTranslate(text, targetLang) } const MANUAL = { es: { 'sidebar.sortAlpha': 'Orden A → Z', 'settings.cardSizeVariable': 'Tamaños variables (pequeño/mediano/grande)', 'brainstorm.aiIdea': 'IA', 'brainstorm.seedNodeBadge': 'SEMILLA', 'byokSettings.badgeActive': 'BYOK activo', 'aiSettings.title': 'IA', 'aiSettings.providerOllama': 'Ollama (local)', 'admin.ai.providerOllamaOption': '🦙 Ollama (local y gratuito)', 'admin.ai.providerLMStudioOption': '🖥️ LM Studio (local)', 'usageMeter.packName': 'Pack descubrimiento IA', 'landing.pricing.perUser': '+ 3,90€/usuario', 'landing.pricing.perUserAnnual': '+ 2,90€/usuario, facturación anual', 'ai.featureLocked': 'Esta función requiere el plan PRO o superior.', 'ai.quotaExceeded': 'Límite mensual alcanzado. Se restablece el próximo mes.', 'profile.tab': 'Perfil', 'about.tab': 'Acerca de', 'appearance.tab': 'Apariencia', 'billing.tab': 'Facturación', 'usageMeter.featureChat': 'Mensajes IA', 'usageMeter.featureReformulate': 'Reformulaciones', 'usageMeter.featureBrainstormCreate': 'Creaciones de brainstorm', 'usageMeter.featureBrainstormExpand': 'Expansiones de brainstorm', 'usageMeter.featureBrainstormEnrich': 'Enriquecimientos de brainstorm', }, it: { 'sidebar.sortAlpha': 'Ordine A → Z', 'settings.cardSizeVariable': 'Dimensioni variabili (small/medium/large)', 'brainstorm.aiIdea': 'IA', 'brainstorm.seedNodeBadge': 'SEME', 'byokSettings.badgeActive': 'BYOK attivo', 'aiSettings.title': 'IA', 'aiSettings.providerOllama': 'Ollama (locale)', 'admin.ai.providerOllamaOption': '🦙 Ollama (locale e gratuito)', 'admin.ai.providerLMStudioOption': '🖥️ LM Studio (locale)', 'usageMeter.packName': 'Pacchetto scoperta IA', 'landing.pricing.perUser': '+ 3,90€/utente', 'landing.pricing.perUserAnnual': '+ 2,90€/utente, fatturazione annuale', 'auth.email': 'Email', 'ai.featureLocked': 'Questa funzione richiede il piano PRO o superiore.', 'ai.quotaExceeded': 'Limite mensile raggiunto. Si azzera il mese prossimo.', 'profile.tab': 'Profilo', 'about.tab': 'Informazioni', 'appearance.tab': 'Aspetto', 'billing.tab': 'Fatturazione', 'usageMeter.featureChat': 'Messaggi IA', 'usageMeter.featureReformulate': 'Riformulazioni', 'usageMeter.featureBrainstormCreate': 'Creazioni brainstorm', 'usageMeter.featureBrainstormExpand': 'Espansioni brainstorm', 'usageMeter.featureBrainstormEnrich': 'Arricchimenti brainstorm', }, pt: { 'sidebar.sortAlpha': 'Ordenação A → Z', 'settings.cardSizeVariable': 'Tamanhos variáveis (pequeno/médio/grande)', 'brainstorm.aiIdea': 'IA', 'brainstorm.seedNodeBadge': 'SEMENTE', 'byokSettings.badgeActive': 'BYOK ativo', 'aiSettings.title': 'IA', 'aiSettings.providerOllama': 'Ollama (local)', 'admin.ai.providerOllamaOption': '🦙 Ollama (local e gratuito)', 'admin.ai.providerLMStudioOption': '🖥️ LM Studio (local)', 'usageMeter.packName': 'Pack descoberta IA', 'landing.pricing.perUser': '+ 3,90€/utilizador', 'landing.pricing.perUserAnnual': '+ 2,90€/utilizador, faturação anual', 'ai.featureLocked': 'Esta funcionalidade requer o plano PRO ou superior.', 'ai.quotaExceeded': 'Limite mensal atingido. Repõe-se no próximo mês.', 'profile.tab': 'Perfil', 'about.tab': 'Sobre', 'appearance.tab': 'Aparência', 'billing.tab': 'Faturação', 'usageMeter.featureChat': 'Mensagens IA', 'usageMeter.featureReformulate': 'Reformulações', 'usageMeter.featureBrainstormCreate': 'Criações de brainstorm', 'usageMeter.featureBrainstormExpand': 'Expansões de brainstorm', 'usageMeter.featureBrainstormEnrich': 'Enriquecimentos de brainstorm', }, nl: { 'sidebar.sortAlpha': 'Sortering A → Z', 'brainstorm.aiIdea': 'AI', 'brainstorm.seedNodeBadge': 'ZAAD', 'byokSettings.badgeActive': 'BYOK actief', 'aiSettings.title': 'AI', 'usageMeter.remaining': '{count} resterend', 'brainstorm.liveOtherParticipants': '{count} andere deelnemers', 'brainstorm.playbackStep': 'Stap {current}/{total}', 'brainstorm.playbackStepsCount': '{count} stappen', 'brainstorm.waveBadge': 'Golf {wave}', 'landing.pricing.perUser': '+ 3,90€/gebruiker', 'landing.pricing.perUserAnnual': '+ 2,90€/gebruiker, jaarlijks gefactureerd', 'ai.featureLocked': 'Deze functie vereist het PRO-abonnement of hoger.', 'ai.quotaExceeded': 'Maandelijkse limiet bereikt. Reset volgende maand.', 'profile.tab': 'Profiel', 'about.tab': 'Over', 'appearance.tab': 'Weergave', 'billing.tab': 'Facturering', 'usageMeter.featureChat': 'AI-berichten', 'usageMeter.featureReformulate': 'Herschrijvingen', 'usageMeter.featureBrainstormCreate': 'Brainstorm-aanmaak', 'usageMeter.featureBrainstormExpand': 'Brainstorm-uitbreidingen', 'usageMeter.featureBrainstormEnrich': 'Brainstorm-verrijkingen', }, pl: { 'sidebar.sortAlpha': 'Sortowanie A → Z', 'brainstorm.aiIdea': 'AI', 'brainstorm.seedNodeBadge': 'NASIONO', 'byokSettings.badgeActive': 'BYOK aktywny', 'aiSettings.title': 'AI', 'usageMeter.remaining': 'Pozostało: {count}', 'brainstorm.liveOtherParticipants': '{count} innych uczestników', 'brainstorm.playbackStep': 'Krok {current}/{total}', 'brainstorm.playbackStepsCount': '{count} kroków', 'brainstorm.waveBadge': 'Fala {wave}', 'landing.pricing.perUser': '+ 3,90€/użytkownika', 'landing.pricing.perUserAnnual': '+ 2,90€/użytkownika, rozliczenie roczne', 'ai.featureLocked': 'Ta funkcja wymaga planu PRO lub wyższego.', 'ai.quotaExceeded': 'Osiągnięto limit miesięczny. Reset w przyszłym miesiącu.', 'profile.tab': 'Profil', 'about.tab': 'O aplikacji', 'appearance.tab': 'Wygląd', 'billing.tab': 'Rozliczenia', 'usageMeter.featureChat': 'Wiadomości AI', 'usageMeter.featureReformulate': 'Przeformułowania', 'usageMeter.featureBrainstormCreate': 'Tworzenia brainstorm', 'usageMeter.featureBrainstormExpand': 'Rozszerzenia brainstorm', 'usageMeter.featureBrainstormEnrich': 'Wzbogacenia brainstorm', }, ru: { 'sidebar.sortAlpha': 'Сортировка A → Z', 'brainstorm.aiIdea': 'ИИ', 'brainstorm.seedNodeBadge': 'СЕМЯ', 'byokSettings.badgeActive': 'BYOK активен', 'aiSettings.title': 'ИИ', 'usageMeter.remaining': 'осталось {count}', 'brainstorm.liveOtherParticipants': '{count} других участников', 'brainstorm.playbackStep': 'Шаг {current}/{total}', 'brainstorm.playbackStepsCount': '{count} шагов', 'brainstorm.waveBadge': 'Волна {wave}', 'landing.pricing.perUser': '+ 3,90€/пользователь', 'landing.pricing.perUserAnnual': '+ 2,90€/пользователь, ежегодная оплата', 'ai.featureLocked': 'Эта функция требует тарифа PRO или выше.', 'ai.quotaExceeded': 'Достигнут месячный лимит. Сброс в следующем месяце.', 'profile.tab': 'Профиль', 'about.tab': 'О приложении', 'appearance.tab': 'Оформление', 'billing.tab': 'Оплата', 'usageMeter.featureChat': 'Сообщения ИИ', 'usageMeter.featureReformulate': 'Переформулировки', 'usageMeter.featureBrainstormCreate': 'Создания brainstorm', 'usageMeter.featureBrainstormExpand': 'Расширения brainstorm', 'usageMeter.featureBrainstormEnrich': 'Обогащения brainstorm', }, ar: { 'brainstorm.aiIdea': 'ذكاء اصطناعي', 'brainstorm.seedNodeBadge': 'بذرة', 'byokSettings.badgeActive': 'BYOK نشط', 'usageMeter.remaining': '{count} متبقي', 'brainstorm.liveOtherParticipants': '{count} مشاركين آخرين', 'brainstorm.playbackStep': 'الخطوة {current}/{total}', 'brainstorm.playbackStepsCount': '{count} خطوات', 'brainstorm.waveBadge': 'موجة {wave}', 'landing.pricing.perUser': '+ 3,90€/مستخدم', 'landing.pricing.perUserAnnual': '+ 3,90€/مستخدم، فوترة سنوية', 'ai.featureLocked': 'تتطلب هذه الميزة خطة PRO أو أعلى.', 'ai.quotaExceeded': 'تم الوصول إلى الحد الشهري. يُعاد التعيين الشهر القادم.', 'profile.tab': 'الملف الشخصي', 'about.tab': 'حول', 'appearance.tab': 'المظهر', 'billing.tab': 'الفوترة', 'usageMeter.featureChat': 'رسائل الذكاء الاصطناعي', 'usageMeter.featureReformulate': 'إعادة الصياغة', 'usageMeter.featureBrainstormCreate': 'إنشاءات العصف الذهني', 'usageMeter.featureBrainstormExpand': 'توسعات العصف الذهني', 'usageMeter.featureBrainstormEnrich': 'إثراءات العصف الذهني', }, } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) async function buildLang(lang) { const fr = flat(JSON.parse(fs.readFileSync(path.join(localesDir, 'fr.json'), 'utf8'))) const keys = worklistKeys(lang) const target = LANG_MAP[lang] const outFile = path.join(outDir, `${lang}.json`) const out = fs.existsSync(outFile) ? JSON.parse(fs.readFileSync(outFile, 'utf8')) : {} const frCache = new Map() console.error(`[${lang}] ${keys.length} keys`) for (let i = 0; i < keys.length; i++) { const key = keys[i] if (MANUAL[lang]?.[key]) { out[key] = MANUAL[lang][key] continue } // Re-translate if placeholders were corrupted (non-ASCII inside braces) const existing = out[key] if (existing && /\{[^}]*[^\x00-\x7F][^}]*\}/.test(existing)) { delete out[key] } const frText = fr[key] if (!frText) { console.error(` skip missing fr: ${key}`) continue } const existingAfter = out[key] if (existingAfter && existingAfter !== frText) { continue } let translated if (frCache.has(frText)) { translated = frCache.get(frText) } else { try { translated = await translateFr(frText, target) frCache.set(frText, translated) await sleep(350) } catch (e) { console.error(` FAIL ${key}:`, e.message) translated = frText } } out[key] = translated if ((i + 1) % 50 === 0) console.error(` ${i + 1}/${keys.length}`) } const file = path.join(outDir, `${lang}.json`) fs.writeFileSync(file, JSON.stringify(out, null, 2) + '\n', 'utf8') console.error(`Wrote ${file} (${Object.keys(out).length} keys)`) } const langs = process.argv.slice(2) const targets = langs.length ? langs : ['es', 'it', 'pt'] for (const lang of targets) { await buildLang(lang) }