Files
Momento/memento-note/scripts/i18n-overrides/generate-overrides.mjs
Antigravity 8c7ca69640
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup
- Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders
- Fix all SQL embedding queries: add ::vector cast on text columns
- Fix embedding truncation to 15000 chars (under 8192 token limit)
- Fix NoteEmbedding INSERT: remove non-existent updatedAt column
- Fix billing page: show all quota stats in grid instead of single metric
- Fix usage meter: accordion expand/collapse, per-feature detail
- Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch
- Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
2026-05-16 18:50:34 +00:00

360 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
}