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
360 lines
14 KiB
JavaScript
360 lines
14 KiB
JavaScript
#!/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)
|
||
}
|