Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,6 @@ DEV_LOG.md

# Screenshots (debug/review)
*.png

# i18n working directory (translation batches, drafts)
.i18n-work/
42 changes: 42 additions & 0 deletions scripts/i18n/_util.js
Original file line number Diff line number Diff line change
@@ -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 };
38 changes: 38 additions & 0 deletions scripts/i18n/apply_translations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// Usage: node scripts/i18n/apply_translations.js <localeCode> <translatedBatchFile.json>
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 <localeCode> <translatedBatchFile.json>');
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);
}
55 changes: 55 additions & 0 deletions scripts/i18n/check_locale.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env node
// Usage: node scripts/i18n/check_locale.js <localeCode>
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 <localeCode>');
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);
29 changes: 29 additions & 0 deletions scripts/i18n/check_placeholders.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Usage: node scripts/i18n/check_placeholders.js <localeCode>
const path = require('path');
const { readJson, flatten, extractPlaceholders } = require('./_util');

const code = process.argv[2];
if (!code) {
console.error('Usage: node check_placeholders.js <localeCode>');
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);
54 changes: 54 additions & 0 deletions scripts/i18n/extract_backlog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env node
// Usage: node scripts/i18n/extract_backlog.js <localeCode> [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 <localeCode> [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}`);
24 changes: 24 additions & 0 deletions scripts/i18n/scaffold_locale.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env node
// Usage: node scripts/i18n/scaffold_locale.js <localeCode>
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 <localeCode>');
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.`);
25 changes: 21 additions & 4 deletions src/renderer/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = [
Expand Down Expand Up @@ -4745,13 +4760,15 @@ function GeneralTab() {
{/* Language */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-text-primary">{t('general.language')}</h4>
<div className="flex gap-2">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => i18n.changeLanguage(lang.code)}
className={`flex-1 px-4 py-2.5 rounded-lg border-2 text-sm font-medium transition-all ${
currentLang === lang.code
dir={lang.code === 'ar' ? 'rtl' : 'ltr'}
lang={lang.code}
className={`px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all ${
currentLang === lang.code || (lang.code === 'zh' && currentLang.startsWith('zh'))
? 'border-accent bg-accent/5 text-text-primary'
: 'border-border bg-surface hover:border-accent/50 text-text-secondary'
}`}
Expand Down
63 changes: 46 additions & 17 deletions src/renderer/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,60 @@ import LanguageDetector from 'i18next-browser-languagedetector';

import enTranslations from './locales/en.json';
import zhTranslations from './locales/zh.json';
import arTranslations from './locales/ar.json';
import ptBRTranslations from './locales/pt-BR.json';
import bsTranslations from './locales/bs.json';
import daTranslations from './locales/da.json';
import deTranslations from './locales/de.json';
import esTranslations from './locales/es.json';
import frTranslations from './locales/fr.json';
import jaTranslations from './locales/ja.json';
import koTranslations from './locales/ko.json';
import nbTranslations from './locales/nb.json';
import plTranslations from './locales/pl.json';
import ruTranslations from './locales/ru.json';
import thTranslations from './locales/th.json';
import trTranslations from './locales/tr.json';
import zhTWTranslations from './locales/zh-TW.json';

i18n
.use(LanguageDetector) // 自动检测浏览器语言
.use(initReactI18next) // 初始化 react-i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: {
translation: enTranslations,
},
zh: {
translation: zhTranslations,
},
en: { translation: enTranslations },
zh: { translation: zhTranslations },
ar: { translation: arTranslations },
'pt-BR': { translation: ptBRTranslations },
bs: { translation: bsTranslations },
da: { translation: daTranslations },
de: { translation: deTranslations },
es: { translation: esTranslations },
fr: { translation: frTranslations },
ja: { translation: jaTranslations },
ko: { translation: koTranslations },
nb: { translation: nbTranslations },
pl: { translation: plTranslations },
ru: { translation: ruTranslations },
th: { translation: thTranslations },
tr: { translation: trTranslations },
'zh-TW': { translation: zhTWTranslations },
},
fallbackLng: 'en', // 默认语言
supportedLngs: ['en', 'zh'], // 支持的语言
fallbackLng: 'en',
supportedLngs: [
'en', 'zh', 'ar', 'pt-BR', 'bs', 'da', 'de', 'es', 'fr',
'ja', 'ko', 'nb', 'pl', 'ru', 'th', 'tr', 'zh-TW',
],
interpolation: {
escapeValue: false, // React 已经处理了 XSS
escapeValue: false,
},
pluralSeparator: '_', // 复数分隔符
contextSeparator: '_', // 上下文分隔符
pluralSeparator: '_',
contextSeparator: '_',
detection: {
order: ['localStorage', 'navigator'], // 先检查 localStorage,再检查浏览器语言
caches: ['localStorage'], // 将语言选择保存到 localStorage
lookupLocalStorage: 'i18nextLng', // localStorage key
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'i18nextLng',
},
});

export default i18n;
export default i18n;
Loading
Loading