mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 00:00:46 +08:00
feat(i18n): add English support for host-side chat messages
Wrap all user-facing chat text in src/channels with a host-side t() translator (Chinese is the identity default, so untranslated text is always sent verbatim in Chinese). English is selected via the host plugin config `language: en` or the DSH_IM_LANGUAGE environment variable, and the settings UI keeps following the DSH web locale. - add src/channels/shared/i18n.mjs with setImHostLanguage/t() and a Chinese-key -> English dictionary split by area (shared + channels) - complete the English dictionary for every t() key in src/channels, including the feishu channel (welcome/help, repair flow, interactive cards, watch flow) and the shared command/approval/stream text - wrap previously untranslated chat-facing strings in feishu bridge, feishu runtime, editable-message-stream, harness-client, connection-test and image-prompt - dedupe dictionary entries that existed in multiple channel files and canonicalize conflicting translations into the shared dictionaries - extend the i18n unit test to decode string-literal escapes so keys containing \n are checked correctly - document the language switch in README.md, README.en.md and CHANGELOG.md, and rebuild lib/index.js Added English support for bot chat messages across all nine IM channels and AI Office without changing any Chinese output.
This commit is contained in:
parent
3ff8c2a519
commit
dcd7cd64d8
78 changed files with 2283 additions and 992 deletions
102
test/channels/shared/i18n.test.mjs
Normal file
102
test/channels/shared/i18n.test.mjs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { after, test } from 'node:test';
|
||||
|
||||
import {
|
||||
getImHostLanguage,
|
||||
setImHostLanguage,
|
||||
t,
|
||||
} from '../../../src/channels/shared/i18n.mjs';
|
||||
import { EN } from '../../../src/channels/shared/i18n-en.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const srcChannelsDir = join(here, '..', '..', '..', 'src', 'channels');
|
||||
|
||||
after(() => {
|
||||
setImHostLanguage('zh');
|
||||
});
|
||||
|
||||
test('host language defaults to Chinese and t() is the identity function', () => {
|
||||
setImHostLanguage(undefined);
|
||||
assert.equal(getImHostLanguage(), 'zh');
|
||||
assert.equal(t('连接成功'), '连接成功');
|
||||
assert.equal(t('共 {count} 个机器人', { count: 3 }), '共 3 个机器人');
|
||||
});
|
||||
|
||||
test('setImHostLanguage accepts English spellings and falls back to Chinese', () => {
|
||||
for (const value of ['en', 'EN', 'en-US', ' english ']) {
|
||||
setImHostLanguage(value);
|
||||
assert.equal(getImHostLanguage(), 'en', `expected ${value} to select English`);
|
||||
}
|
||||
for (const value of [undefined, null, '', 'zh', 'zh-CN', 'fr', 42]) {
|
||||
setImHostLanguage(value);
|
||||
assert.equal(getImHostLanguage(), 'zh', `expected ${value} to select Chinese`);
|
||||
}
|
||||
});
|
||||
|
||||
test('t() translates known keys and fills placeholders in English mode', () => {
|
||||
setImHostLanguage('en');
|
||||
const [key] = Object.keys(EN);
|
||||
if (key) {
|
||||
assert.equal(t(key), EN[key]);
|
||||
}
|
||||
assert.equal(t('未收录的中文'), '未收录的中文');
|
||||
assert.equal(t('{value} 测试', { value: 'x' }), EN['{value} 测试'] ?? 'x 测试');
|
||||
assert.equal(t(42), 42);
|
||||
});
|
||||
|
||||
test('every English dictionary entry is a non-empty translation of a Chinese key', () => {
|
||||
for (const [key, value] of Object.entries(EN)) {
|
||||
assert.ok(/[一-鿿]/.test(key), `dictionary key is not Chinese: ${key}`);
|
||||
assert.ok(typeof value === 'string' && value.trim().length > 0, `empty translation for: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every literal t() key in src/channels has an English dictionary entry', () => {
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
if (statSync(path).isDirectory()) {
|
||||
if (entry !== 'i18n-en') walk(path);
|
||||
} else if (entry.endsWith('.mjs') && entry !== 'i18n.mjs' && entry !== 'i18n-en.mjs') {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(srcChannelsDir);
|
||||
|
||||
const keyPattern = /\bt\(\s*(['`])((?:[^\\`'"$]|\$(?!\{)|\\.)*)\1/g;
|
||||
// Decode the escapes a real string literal would apply, so keys written as
|
||||
// `...\n...` in source compare against the runtime dictionary key (newline).
|
||||
const decodeLiteral = (raw) => {
|
||||
let out = '';
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
if (raw[i] !== '\\' || i + 1 >= raw.length) {
|
||||
out += raw[i];
|
||||
continue;
|
||||
}
|
||||
const next = raw[i + 1];
|
||||
if (next === 'n') out += '\n';
|
||||
else if (next === 't') out += '\t';
|
||||
else if (next === 'r') out += '\r';
|
||||
else out += next; // \', \", \\, \` and anything else keep the char
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const missing = [];
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(keyPattern)) {
|
||||
const key = decodeLiteral(match[2]);
|
||||
if (!/[一-鿿]/.test(key)) continue;
|
||||
if (!Object.hasOwn(EN, key)) {
|
||||
missing.push(`${file}: ${match[2]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(missing, [], 't() keys missing from the English dictionary');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue