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) => (