mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 20:13:21 +08:00
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.
102 lines
3.5 KiB
JavaScript
102 lines
3.5 KiB
JavaScript
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');
|
|
});
|