feat(i18n): add zh-TW locale and sync 74 missing keys in zh-CN (#1017) (#1025)

* fix(i18n): sync zh-CN to match en structure

- Add 74 missing keys covering login.resetPassword, printers.firmwareModal
  badges, settings.spoolbuddy device management, settings.tabs.spoolbuddy,
  spoolbuddy.settings system config
- Fix fileManager.uploadFailed placeholder bug (had stray {{count}} copied
  from zipFilesFailed; en value is plain "Upload failed")

Refs #1017

* feat(i18n): add zh-TW locale and enforce 3-way parity

- Add frontend/src/i18n/locales/zh-TW.ts (Traditional Chinese, Taiwan usage)
  with full key set aligned to en.ts
- Register zh-TW in frontend/src/i18n/index.ts: import, resources,
  supportedLngs, availableLanguages
- Add frontend/scripts/check-i18n-parity.mjs: TypeScript Compiler API-based
  3-way gate checking key set equality, placeholder equality, and legacy
  _plural / _one+_other suffix handling across en / zh-CN / zh-TW
- Wire check:i18n into test:run npm script so frontend-tests CI job
  (ci.yml:227) gates future locale drift

Fixes #1017
This commit is contained in:
Minidoracat
2026-04-19 08:17:09 +02:00
committed by GitHub
parent bb999c6805
commit a584e671ec
5 changed files with 5322 additions and 4 deletions
+3 -2
View File
@@ -9,9 +9,10 @@
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:run": "vitest run && npm run check:i18n",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
"test:ui": "vitest --ui",
"check:i18n": "node scripts/check-i18n-parity.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
// Verifies parity across locale files (en / zh-CN / zh-TW):
// 1. Leaf-key sets are identical
// 2. Each leaf's {{placeholder}} set is identical
// 3. Plural suffixes: every en key ending in _plural / _one / _other must
// exist in every other locale, and other locales must not introduce an
// _one key that en does not have.
// Malformed input (missing `export default`, parse errors, non-string leaves,
// unsupported property kinds) fails loudly instead of silently passing the gate.
// Exits 1 with a diagnostic report on any failure, else exits 0.
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
const frontendDir = path.resolve(scriptDir, '..');
const localesDir = path.join(frontendDir, 'src/i18n/locales');
const tsPath = path.join(frontendDir, 'node_modules/typescript/lib/typescript.js');
const tsModule = await import(url.pathToFileURL(tsPath).href);
const ts = tsModule.default ?? tsModule;
function collectLeaves(node, prefix, leaves) {
if (!ts.isObjectLiteralExpression(node)) return;
for (const prop of node.properties) {
if (!ts.isPropertyAssignment(prop)) {
console.error(
`Unsupported property kind ${ts.SyntaxKind[prop.kind]} at "${prefix}" ` +
`(locale files must use plain \`key: value\` assignments — no spread, shorthand, methods, or accessors).`,
);
process.exit(1);
}
let name;
if (ts.isIdentifier(prop.name)) name = prop.name.text;
else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) name = prop.name.text;
else if (ts.isComputedPropertyName(prop.name)) {
console.error(`ComputedPropertyName not allowed in locale file at path "${prefix}"`);
process.exit(1);
} else {
console.error(`Unsupported property-name kind ${ts.SyntaxKind[prop.name.kind]} at "${prefix}"`);
process.exit(1);
}
const p = prefix ? `${prefix}.${name}` : name;
if (ts.isObjectLiteralExpression(prop.initializer)) {
collectLeaves(prop.initializer, p, leaves);
} else {
const value = extractStringValue(prop.initializer, p);
leaves.set(p, value);
}
}
}
function extractStringValue(node, keyPath) {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
if (ts.isTemplateExpression(node)) {
let out = node.head.text;
for (const span of node.templateSpans) {
out += '${' + span.expression.getText() + '}';
out += span.literal.text;
}
return out;
}
console.error(
`Non-string leaf at "${keyPath}" (kind=${ts.SyntaxKind[node.kind]}): ${node.getText()}\n` +
`Locale files must only contain string or template literals as leaf values.`,
);
process.exit(1);
}
function loadLocale(filePath) {
const src = fs.readFileSync(filePath, 'utf8');
const sf = ts.createSourceFile(filePath, src, ts.ScriptTarget.Latest, true);
if (sf.parseDiagnostics && sf.parseDiagnostics.length > 0) {
console.error(`${filePath}: ${sf.parseDiagnostics.length} parse error(s):`);
for (const d of sf.parseDiagnostics.slice(0, 10)) {
const msg = typeof d.messageText === 'string' ? d.messageText : d.messageText.messageText;
const { line, character } = sf.getLineAndCharacterOfPosition(d.start ?? 0);
console.error(` ${line + 1}:${character + 1} ${msg}`);
}
process.exit(1);
}
const leaves = new Map();
let foundExport = false;
ts.forEachChild(sf, (n) => {
if (ts.isExportAssignment(n)) {
foundExport = true;
collectLeaves(n.expression, '', leaves);
}
});
if (!foundExport) {
console.error(`${filePath}: no \`export default\` found — locale files must use \`export default { ... }\`.`);
process.exit(1);
}
if (leaves.size === 0) {
console.error(`${filePath}: \`export default\` resolved to zero leaves — file is empty or not a nested object.`);
process.exit(1);
}
return leaves;
}
const locales = {
en: loadLocale(path.join(localesDir, 'en.ts')),
'zh-CN': loadLocale(path.join(localesDir, 'zh-CN.ts')),
'zh-TW': loadLocale(path.join(localesDir, 'zh-TW.ts')),
};
let failed = false;
const MAX_REPORT = 20;
function reportList(label, items) {
if (items.length === 0) return;
failed = true;
console.error(`\n[${label}] (${items.length})`);
items.slice(0, MAX_REPORT).forEach((i) => console.error(` ${i}`));
if (items.length > MAX_REPORT) console.error(` ... and ${items.length - MAX_REPORT} more`);
}
// Check 1: key set equality
const enKeys = new Set(locales.en.keys());
for (const [code, map] of Object.entries(locales)) {
if (code === 'en') continue;
const keys = new Set(map.keys());
const missing = [...enKeys].filter((k) => !keys.has(k)).sort();
const extra = [...keys].filter((k) => !enKeys.has(k)).sort();
reportList(`${code}: missing keys vs en`, missing);
reportList(`${code}: extra keys vs en`, extra);
}
// Check 2: placeholder set equality per leaf
const placeholderRe = /\{\{[^{}]+\}\}/g;
for (const [code, map] of Object.entries(locales)) {
if (code === 'en') continue;
const mismatches = [];
for (const [key, enValue] of locales.en) {
const otherValue = map.get(key);
if (otherValue === undefined) continue;
const enPlaceholders = new Set((enValue.match(placeholderRe) ?? []));
const otherPlaceholders = new Set((otherValue.match(placeholderRe) ?? []));
const missingPh = [...enPlaceholders].filter((p) => !otherPlaceholders.has(p));
const extraPh = [...otherPlaceholders].filter((p) => !enPlaceholders.has(p));
if (missingPh.length || extraPh.length) {
mismatches.push(`${key}: en=${[...enPlaceholders].join(',') || '∅'} vs ${code}=${[...otherPlaceholders].join(',') || '∅'}`);
}
}
reportList(`${code}: placeholder mismatch vs en`, mismatches);
}
// Check 3: plural suffix presence + reverse _one guard
for (const [code, map] of Object.entries(locales)) {
if (code === 'en') continue;
const pluralIssues = [];
for (const key of enKeys) {
if (key.endsWith('_plural') && !map.has(key)) pluralIssues.push(`missing _plural key: ${key}`);
if (key.endsWith('_one') && !map.has(key)) pluralIssues.push(`missing _one key: ${key}`);
if (key.endsWith('_other') && !map.has(key)) pluralIssues.push(`missing _other key: ${key}`);
}
for (const key of map.keys()) {
if (key.endsWith('_one') && !enKeys.has(key)) {
pluralIssues.push(`unexpected _one not present in en: ${key}`);
}
}
reportList(`${code}: plural key mismatch`, pluralIssues);
}
if (failed) {
console.error('\n❌ i18n parity check failed.');
process.exit(1);
}
console.log(`All locales in parity (en / zh-CN / zh-TW):`);
for (const [code, map] of Object.entries(locales)) {
console.log(` ${code}: ${map.size} leaves`);
}
+4 -1
View File
@@ -10,6 +10,7 @@ import ja from './locales/ja';
import it from './locales/it';
import ptBR from './locales/pt-BR';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
const resources = {
en: { translation: en },
@@ -19,6 +20,7 @@ const resources = {
it: { translation: it },
'pt-BR': { translation: ptBR },
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
};
i18n
@@ -27,7 +29,7 @@ i18n
.init({
resources,
fallbackLng: 'en',
supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR', 'zh-CN'],
supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR', 'zh-CN', 'zh-TW'],
detection: {
// Order of detection methods
@@ -58,4 +60,5 @@ export const availableLanguages = [
{ code: 'it', name: 'Italian', nativeName: 'Italiano' },
{ code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
{ code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
];
+80 -1
View File
@@ -566,6 +566,13 @@ export default {
uploadFirmware: '上传固件',
uploadFailed: '上传启动失败:{{error}}',
uploadedToast: '固件已上传!请在打印机屏幕上触发更新。',
availableVersions: '可用版本',
usable: '可用',
unavailable: '不可用',
installed: '已安装',
newerBadge: '较新',
olderBadge: '较旧',
currentBadge: '当前',
},
accessCodePlaceholder: '留空以保持当前值',
// ROI editor
@@ -1335,6 +1342,7 @@ export default {
network: '网络',
apiKeys: 'API 密钥',
virtualPrinter: '虚拟打印机',
spoolbuddy: 'SpoolBuddy',
failureDetection: '故障检测',
users: '身份验证',
backup: '备份',
@@ -1343,6 +1351,50 @@ export default {
twoFa: '双因素认证',
oidc: 'SSO / OIDC',
},
spoolbuddy: {
infoTitle: 'SpoolBuddy 设备',
infoBody: 'SpoolBuddy kiosk 通过心跳自动注册。如果设备不再使用,或守护进程崩溃遗留了陈旧的重复项,可在此注销。',
duplicatesTitle: '已注册 {{count}} 台设备',
duplicatesBody: 'kiosk 界面只使用最先注册的设备。如果其中有因崩溃遗留的陈旧重复项,请注销它——在线设备会在下次心跳时重新注册自己。',
empty: '尚未注册任何 SpoolBuddy 设备。',
online: '在线',
offline: '离线',
unregister: '注销',
unregisterSuccess: '设备已注销',
unregisterError: '注销设备失败',
confirmTitle: '注销 SpoolBuddy 设备?',
confirmBody: '将从数据库中移除 "{{hostname}}" ({{deviceId}})。如果设备在线,会在下次心跳时重新注册自己。',
ipAddress: 'IP 地址',
firmware: '固件',
lastSeen: '上次在线',
daemonUptime: '守护进程运行时间',
systemUptime: '系统运行时间',
never: '从未',
nfc: 'NFC',
scale: '秤',
cpuTemp: 'CPU 温度',
memory: '内存',
disk: '磁盘',
// Device actions
update: '更新',
updateConfirmTitle: '更新 SpoolBuddy 守护进程?',
updateConfirmBody: '对 "{{hostname}}" 触发软件更新?更新完成后守护进程将重启。',
restartBrowser: '重启浏览器',
restartBrowserConfirmTitle: '重启 kiosk 浏览器?',
restartBrowserConfirmBody: '在 "{{hostname}}" 上重启 kiosk 浏览器?显示将短暂黑屏。',
restartDaemon: '重启守护进程',
restartDaemonConfirmTitle: '重启 SpoolBuddy 守护进程?',
restartDaemonConfirmBody: '在 "{{hostname}}" 上重启 SpoolBuddy 守护进程?设备将离线几秒钟。',
reboot: '重启',
rebootConfirmTitle: '重启设备?',
rebootConfirmBody: '重启 "{{hostname}}"?设备将离线约一分钟。',
shutdown: '关机',
shutdownConfirmTitle: '关闭设备?',
shutdownConfirmBody: '关闭 "{{hostname}}"?您需要物理访问才能重新开机。',
commandConfirm: '确认',
commandQueued: '命令已加入队列',
commandError: '发送命令失败',
},
ldap: {
title: 'LDAP 认证',
enabledDesc: 'LDAP 认证已启用',
@@ -2292,6 +2344,20 @@ export default {
resetStep3: '他们可以为您设置一个临时密码',
resetStep4: '使用新密码登录并在设置中修改密码',
gotIt: '知道了',
resetPassword: {
title: '设置新密码',
subtitle: '请在下方输入并确认您的新密码。',
newPassword: '新密码',
newPasswordPlaceholder: '至少 8 个字符',
confirmPassword: '确认密码',
confirmPasswordPlaceholder: '重复输入新密码',
saving: '保存中…',
submit: '设置新密码',
backToLogin: '返回登录',
passwordsDoNotMatch: '密码不匹配',
passwordTooShort: '密码至少需要 8 个字符',
resetFailed: '密码重置失败。链接可能已过期。',
},
twoFA: {
title: '两步验证',
subtitle: '您的账户已启用两步验证。请在下方输入验证码。',
@@ -2792,7 +2858,7 @@ export default {
willBeExtracted: '将被解压',
filesExtracted: '已解压 {{count}} 个文件',
uploadComplete: '上传完成:{{succeeded}} 个成功',
uploadFailed: '{{count}} 个失败',
uploadFailed: '上传失败',
zipFilesFailed: '{{count}} 个文件失败',
uploading: '上传中...',
changeLink: '更改链接...',
@@ -4825,6 +4891,19 @@ export default {
deviceInfo: '设备信息',
hostname: '主机',
uptime: '运行时间',
systemConfig: '后端与认证',
backendUrl: 'Bambuddy 后端 URL',
apiToken: 'API 令牌',
apiTokenPlaceholder: '输入 API 令牌',
saveConfig: '保存配置',
systemQueued: '配置已加入队列。',
nfcDiagnostic: 'NFC 诊断',
scaleDiagnostic: '秤诊断',
readTagDiagnostic: '读取标签诊断',
testNfc: '测试读卡器',
testScale: '测试精度',
testReadTag: '读取标签',
systemFieldsRequired: '后端 URL 为必填项。',
// Display tab
brightness: '亮度',
saved: '已保存',
File diff suppressed because it is too large Load Diff