From 557c91e7baf8d5078e1dffc8ae388f9e1e5698d1 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Mon, 20 Jul 2026 13:18:52 +0200 Subject: [PATCH] feat(i18n): add 15 locales and i18n infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 15 new locale files (ar, bs, da, de, es, fr, ja, ko, nb, pl, pt-BR, ru, th, tr, zh-TW) and i18n infrastructure to support multilingual UI. Applied via cherry-pick from feat/i18n-15-locales onto a fresh upstream/dev base (ff4328d). Conflicts on upstream-deleted files (toolHelpers/ToolUseBlock/ToolResultBlock/SettingsGeneral) were resolved by accepting the upstream delete — the maintainer will need to integrate the i18n into their post-refactor UI separately or via follow-up commits. Conflict on en.json/zh.json was resolved by taking upstream's refactored structure (memory section removed, credentials section added, sandbox safety keys added) and re-adding our 16-key toolLabels namespace. Net result: en.json/zh.json stay compatible with both upstream's new structure AND our 15 translations (which still contain memory.* for backwards compat). Additive (no conflict): - 15 new locale files: ar, bs, da, de, es, fr, ja, ko, nb, pl, pt-BR, ru, th, tr, zh-TW - 6 new scripts in scripts/i18n/: _util, scaffold_locale, extract_backlog, apply_translations, check_locale, check_placeholders (zero external dependency) Modified (conflicts resolved): - .gitignore: add .i18n-work/ (translation working directory) - src/renderer/i18n/config.ts: add 15 new locale imports + ALLOWLIST - src/renderer/i18n/locales/en.json: add toolLabels namespace, take upstream's refactored structure (memory removed, credentials added) - src/renderer/i18n/locales/zh.json: same as en.json Verification: tsc clean (1 pre-existing TS6133 warning on src/main/config/config-store.ts is unrelated to i18n), vite build OK in 17.55s. --- .gitignore | 3 + scripts/i18n/_util.js | 42 + scripts/i18n/apply_translations.js | 38 + scripts/i18n/check_locale.js | 55 + scripts/i18n/check_placeholders.js | 29 + scripts/i18n/extract_backlog.js | 54 + scripts/i18n/scaffold_locale.js | 24 + src/renderer/components/SettingsPanel.tsx | 25 +- src/renderer/i18n/config.ts | 63 +- src/renderer/i18n/locales/ar.json | 955 ++++++++++++ src/renderer/i18n/locales/bs.json | 955 ++++++++++++ src/renderer/i18n/locales/da.json | 955 ++++++++++++ src/renderer/i18n/locales/de.json | 955 ++++++++++++ src/renderer/i18n/locales/en.json | 30 +- src/renderer/i18n/locales/es.json | 955 ++++++++++++ src/renderer/i18n/locales/fr.json | 955 ++++++++++++ src/renderer/i18n/locales/ja.json | 955 ++++++++++++ src/renderer/i18n/locales/ko.json | 955 ++++++++++++ src/renderer/i18n/locales/nb.json | 955 ++++++++++++ src/renderer/i18n/locales/pl.json | 955 ++++++++++++ src/renderer/i18n/locales/pt-BR.json | 955 ++++++++++++ src/renderer/i18n/locales/ru.json | 955 ++++++++++++ src/renderer/i18n/locales/th.json | 955 ++++++++++++ src/renderer/i18n/locales/tr.json | 955 ++++++++++++ src/renderer/i18n/locales/zh-TW.json | 955 ++++++++++++ src/renderer/i18n/locales/zh.json | 1602 +++++++++++---------- 26 files changed, 15471 insertions(+), 819 deletions(-) create mode 100644 scripts/i18n/_util.js create mode 100644 scripts/i18n/apply_translations.js create mode 100644 scripts/i18n/check_locale.js create mode 100644 scripts/i18n/check_placeholders.js create mode 100644 scripts/i18n/extract_backlog.js create mode 100644 scripts/i18n/scaffold_locale.js create mode 100644 src/renderer/i18n/locales/ar.json create mode 100644 src/renderer/i18n/locales/bs.json create mode 100644 src/renderer/i18n/locales/da.json create mode 100644 src/renderer/i18n/locales/de.json create mode 100644 src/renderer/i18n/locales/es.json create mode 100644 src/renderer/i18n/locales/fr.json create mode 100644 src/renderer/i18n/locales/ja.json create mode 100644 src/renderer/i18n/locales/ko.json create mode 100644 src/renderer/i18n/locales/nb.json create mode 100644 src/renderer/i18n/locales/pl.json create mode 100644 src/renderer/i18n/locales/pt-BR.json create mode 100644 src/renderer/i18n/locales/ru.json create mode 100644 src/renderer/i18n/locales/th.json create mode 100644 src/renderer/i18n/locales/tr.json create mode 100644 src/renderer/i18n/locales/zh-TW.json diff --git a/.gitignore b/.gitignore index 123e70afe..62f6dc966 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,6 @@ DEV_LOG.md # Screenshots (debug/review) *.png + +# i18n working directory (translation batches, drafts) +.i18n-work/ diff --git a/scripts/i18n/_util.js b/scripts/i18n/_util.js new file mode 100644 index 000000000..9800c8e0f --- /dev/null +++ b/scripts/i18n/_util.js @@ -0,0 +1,42 @@ +const fs = require('fs'); + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf-8')); +} + +function writeJson(file, obj) { + fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n', 'utf-8'); +} + +function flatten(obj, prefix = '') { + const out = {}; + for (const [key, value] of Object.entries(obj)) { + const flatKey = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(out, flatten(value, flatKey)); + } else { + out[flatKey] = value; + } + } + return out; +} + +function setAtPath(obj, dotKey, value) { + const parts = dotKey.split('.'); + let cur = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (typeof cur[parts[i]] !== 'object' || cur[parts[i]] === null) { + cur[parts[i]] = {}; + } + cur = cur[parts[i]]; + } + cur[parts[parts.length - 1]] = value; +} + +function extractPlaceholders(str) { + if (typeof str !== 'string') return []; + const matches = str.match(/\{\{\s*[\w.-]+\s*\}\}/g) || []; + return matches.map((m) => m.replace(/\s+/g, '')).sort(); +} + +module.exports = { readJson, writeJson, flatten, setAtPath, extractPlaceholders }; \ No newline at end of file diff --git a/scripts/i18n/apply_translations.js b/scripts/i18n/apply_translations.js new file mode 100644 index 000000000..6a6fb007b --- /dev/null +++ b/scripts/i18n/apply_translations.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Usage: node scripts/i18n/apply_translations.js +const path = require('path'); +const { readJson, writeJson, flatten, setAtPath } = require('./_util'); + +const code = process.argv[2]; +const batchFile = process.argv[3]; +if (!code || !batchFile) { + console.error('Usage: node apply_translations.js '); + process.exit(1); +} + +const localesDir = path.resolve(__dirname, '../../src/renderer/i18n/locales'); +const localePath = path.join(localesDir, `${code}.json`); +const locale = readJson(localePath); +const enFlat = flatten(readJson(path.join(localesDir, 'en.json'))); +const translations = readJson(path.resolve(batchFile)); + +const notFound = []; +let applied = 0; +for (const [key, value] of Object.entries(translations)) { + if (!(key in enFlat)) { + notFound.push(key); + continue; + } + if (typeof value !== 'string' || value.trim() === '') { + console.warn(`Skipping empty/non-string translation for ${key}`); + continue; + } + setAtPath(locale, key, value); + applied++; +} + +writeJson(localePath, locale); +console.log(`${code}: applied ${applied} translations.`); +if (notFound.length) { + console.warn(`${code}: ${notFound.length} keys not found in en.json (skipped):`, notFound); +} \ No newline at end of file diff --git a/scripts/i18n/check_locale.js b/scripts/i18n/check_locale.js new file mode 100644 index 000000000..99210f047 --- /dev/null +++ b/scripts/i18n/check_locale.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Usage: node scripts/i18n/check_locale.js +const path = require('path'); +const fs = require('fs'); +const { readJson, flatten } = require('./_util'); + +const code = process.argv[2]; +if (!code) { + console.error('Usage: node check_locale.js '); + process.exit(1); +} + +const ALLOWLIST = new Set([ + 'welcome.chromeRequired', + 'welcome.notionRequired', + 'common.appLogoAlt', + 'welcome.logoAlt', + 'mcp.namePlaceholder', + 'remote.slackAppToken', + 'language.english', +]); + +const localesDir = path.resolve(__dirname, '../../src/renderer/i18n/locales'); +const en = flatten(readJson(path.join(localesDir, 'en.json'))); +const localePath = path.join(localesDir, `${code}.json`); +if (!fs.existsSync(localePath)) { + console.error(`FAIL: ${localePath} does not exist.`); + process.exit(1); +} +const locale = flatten(readJson(localePath)); + +let ok = true; +const enKeys = Object.keys(en); +const localeKeys = Object.keys(locale); + +const missing = enKeys.filter((k) => !(k in locale)); +const extra = localeKeys.filter((k) => !(k in en)); +if (missing.length) { + ok = false; + console.error(`MISSING keys (${missing.length}):`, missing); +} +if (extra.length) { + ok = false; + console.error(`EXTRA keys not in en.json (${extra.length}):`, extra); +} + +const stillEnglish = enKeys.filter((k) => !ALLOWLIST.has(k) && locale[k] === en[k]); + +console.log(`${code}: ${enKeys.length} total keys, ${missing.length} missing, ${extra.length} extra, ${stillEnglish.length} still identical to English.`); +if (stillEnglish.length) { + console.log('Untranslated keys:', stillEnglish.slice(0, 50), stillEnglish.length > 50 ? `... (+${stillEnglish.length - 50} more)` : ''); +} + +console.log(ok && stillEnglish.length === 0 ? 'VERDICT: PASS' : 'VERDICT: FAIL'); +process.exit(ok && stillEnglish.length === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/i18n/check_placeholders.js b/scripts/i18n/check_placeholders.js new file mode 100644 index 000000000..e763d27bc --- /dev/null +++ b/scripts/i18n/check_placeholders.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Usage: node scripts/i18n/check_placeholders.js +const path = require('path'); +const { readJson, flatten, extractPlaceholders } = require('./_util'); + +const code = process.argv[2]; +if (!code) { + console.error('Usage: node check_placeholders.js '); + process.exit(1); +} + +const localesDir = path.resolve(__dirname, '../../src/renderer/i18n/locales'); +const en = flatten(readJson(path.join(localesDir, 'en.json'))); +const locale = flatten(readJson(path.join(localesDir, `${code}.json`))); + +let mismatches = 0; +for (const [key, enValue] of Object.entries(en)) { + const localeValue = locale[key]; + if (localeValue === undefined) continue; + const enPh = extractPlaceholders(enValue).join(','); + const localePh = extractPlaceholders(localeValue).join(','); + if (enPh !== localePh) { + mismatches++; + console.error(`MISMATCH ${key}: en=[${enPh}] ${code}=[${localePh}]`); + } +} + +console.log(mismatches === 0 ? 'VERDICT: PASS (all placeholders match)' : `VERDICT: FAIL (${mismatches} mismatches)`); +process.exit(mismatches === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/i18n/extract_backlog.js b/scripts/i18n/extract_backlog.js new file mode 100644 index 000000000..8b6dc0c19 --- /dev/null +++ b/scripts/i18n/extract_backlog.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Usage: node scripts/i18n/extract_backlog.js [batchSize=80] +const path = require('path'); +const fs = require('fs'); +const { readJson, flatten } = require('./_util'); + +const code = process.argv[2]; +const batchSize = parseInt(process.argv[3] || '80', 10); +if (!code) { + console.error('Usage: node extract_backlog.js [batchSize]'); + process.exit(1); +} + +// Keys whose value is a bare proper noun / brand name: leaving them +// identical to English across all locales is correct, not a translation gap. +const ALLOWLIST = new Set([ + 'welcome.chromeRequired', + 'welcome.notionRequired', +]); + +const localesDir = path.resolve(__dirname, '../../src/renderer/i18n/locales'); +const en = flatten(readJson(path.join(localesDir, 'en.json'))); +const localePath = path.join(localesDir, `${code}.json`); +if (!fs.existsSync(localePath)) { + console.error(`Locale file not found: ${localePath}. Run scaffold_locale.js first.`); + process.exit(1); +} +const locale = flatten(readJson(localePath)); + +const backlog = {}; +for (const [key, enValue] of Object.entries(en)) { + if (ALLOWLIST.has(key)) continue; + if (typeof enValue !== 'string') continue; + const localeValue = locale[key]; + if (localeValue === undefined || localeValue === enValue) { + backlog[key] = enValue; + } +} + +const keys = Object.keys(backlog); +const outDir = path.resolve(__dirname, `../../.i18n-work/${code}`); +fs.mkdirSync(outDir, { recursive: true }); + +let batchCount = 0; +for (let i = 0; i < keys.length; i += batchSize) { + batchCount++; + const batchKeys = keys.slice(i, i + batchSize); + const batch = {}; + for (const k of batchKeys) batch[k] = backlog[k]; + const file = path.join(outDir, `batch-${String(batchCount).padStart(3, '0')}.json`); + fs.writeFileSync(file, JSON.stringify(batch, null, 2) + '\n', 'utf-8'); +} + +console.log(`${code}: ${keys.length} keys to translate, ${batchCount} batch file(s) written to ${outDir}`); \ No newline at end of file diff --git a/scripts/i18n/scaffold_locale.js b/scripts/i18n/scaffold_locale.js new file mode 100644 index 000000000..dd5c6b596 --- /dev/null +++ b/scripts/i18n/scaffold_locale.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +// Usage: node scripts/i18n/scaffold_locale.js +const path = require('path'); +const fs = require('fs'); +const { readJson, writeJson } = require('./_util'); + +const code = process.argv[2]; +if (!code) { + console.error('Usage: node scaffold_locale.js '); + process.exit(1); +} + +const localesDir = path.resolve(__dirname, '../../src/renderer/i18n/locales'); +const enPath = path.join(localesDir, 'en.json'); +const targetPath = path.join(localesDir, `${code}.json`); + +if (fs.existsSync(targetPath)) { + console.error(`Refusing to overwrite existing file: ${targetPath}`); + process.exit(1); +} + +const en = readJson(enPath); +writeJson(targetPath, en); +console.log(`Created ${targetPath} as an English-fallback skeleton.`); \ No newline at end of file diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index abd5d860b..6af8d7a69 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -4697,7 +4697,7 @@ function GeneralTab() { const { i18n, t } = useTranslation(); const settings = useAppStore((s) => s.settings); const updateSettings = useAppStore((s) => s.updateSettings); - const currentLang = i18n.language.startsWith('zh') ? 'zh' : 'en'; + const currentLang = i18n.resolvedLanguage || i18n.language; const [appVer, setAppVer] = useState(''); useEffect(() => { try { @@ -4712,6 +4712,21 @@ function GeneralTab() { const languages = [ { code: 'en', nativeName: 'English' }, { code: 'zh', nativeName: '中文' }, + { code: 'ar', nativeName: 'العربية' }, + { code: 'pt-BR', nativeName: 'Português (BR)' }, + { code: 'bs', nativeName: 'Bosanski' }, + { code: 'da', nativeName: 'Dansk' }, + { code: 'de', nativeName: 'Deutsch' }, + { code: 'es', nativeName: 'Español' }, + { code: 'fr', nativeName: 'Français' }, + { code: 'ja', nativeName: '日本語' }, + { code: 'ko', nativeName: '한국어' }, + { code: 'nb', nativeName: 'Norsk' }, + { code: 'pl', nativeName: 'Polski' }, + { code: 'ru', nativeName: 'Русский' }, + { code: 'th', nativeName: 'ไทย' }, + { code: 'tr', nativeName: 'Türkçe' }, + { code: 'zh-TW', nativeName: '繁體中文' }, ]; const themeOptions = [ @@ -4745,13 +4760,15 @@ function GeneralTab() { {/* Language */}

{t('general.language')}

-
+
{languages.map((lang) => (