fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- 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
This commit is contained in:
359
memento-note/scripts/i18n-overrides/generate-overrides.mjs
Normal file
359
memento-note/scripts/i18n-overrides/generate-overrides.mjs
Normal file
@@ -0,0 +1,359 @@
|
||||
#!/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)
|
||||
}
|
||||
Reference in New Issue
Block a user