Expand i18n language support

Change-Id: I31bf34852ea46947dab186a46942839db7eaaaca
This commit is contained in:
wujunchen 2026-05-19 13:12:11 +08:00
parent 68bfed7f56
commit 2faf1729f2
12 changed files with 1347 additions and 30 deletions

View file

@ -39,7 +39,7 @@ Inspired by [this reading workflow demo](https://www.bilibili.com/video/BV1FxoGB
- **Rich rendering** — cards render through Obsidian's `MarkdownRenderer`, so tables, bold, code, and wikilinks all work natively.
- **Card editing** — right-click any card to copy, edit, delete, or jump to source.
- **Export** — save cards as a Markdown note in your vault, or copy to clipboard.
- **Bilingual UI** — full Chinese and English support for commands, settings, and notices.
- **Multi-language UI and output** — UI supports Auto, Chinese, English, Japanese, Korean, French, German, and Spanish; generated titles, gists, and bullets can use the same fixed languages or follow the source document.
## Quick Start

View file

@ -39,7 +39,7 @@
- **Markdown 渲染** — 通过 Obsidian 的 `MarkdownRenderer` 渲染表格、加粗、代码、wikilink 均正常显示。
- **卡片编辑** — 右键任意卡片:复制、编辑、删除、跳转原文。
- **导出** — 保存为 Vault 中的 Markdown 文件,或复制到剪贴板。
- **中英双语 UI** — 命令、设置、面板文案全部支持中文和英文
- **多语言 UI 与输出** — UI 支持 Auto、中文、英文、日文、韩文、法文、德文、西班牙文生成的 title/gist/bullets 也可选择这些固定语言,或跟随原文语言
## 快速开始

View file

@ -1,7 +1,7 @@
{
"id": "parallel-reader",
"name": "Parallel Reader",
"version": "1.0.22",
"version": "1.0.23",
"minAppVersion": "1.8.7",
"description": "AI-powered split-view reading: source note on the left, LLM-generated summary cards on the right with scroll-sync highlighting.",
"author": "lancivez",

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,23 @@
import { STRINGS } from './i18n-strings';
import type { PluginSettings } from './types';
export { STRINGS } from './i18n-strings';
export { LOCALE_OVERRIDES, STRINGS } from './i18n-strings';
function supportedBaseLanguage(value: unknown): string | null {
const base = String(value || '')
.trim()
.toLowerCase()
.split(/[-_]/)[0];
return base && STRINGS[base] ? base : null;
}
export function resolveUiLanguage(settings: Pick<PluginSettings, 'uiLanguage'> | null): string {
const configured = settings?.uiLanguage;
if (configured === 'zh' || configured === 'en') return configured;
if (configured && configured !== 'auto') {
return supportedBaseLanguage(configured) || 'en';
}
const nav = typeof navigator !== 'undefined' ? navigator : null;
const language = String(nav?.language || '').toLowerCase();
return language.startsWith('zh') ? 'zh' : 'en';
return supportedBaseLanguage(nav?.language) || 'en';
}
export function translate(

View file

@ -3,21 +3,51 @@
import { DEFAULT_SETTINGS, MAX_DOC_CHARS, normalizeCardCount, PROMPT_LANGUAGES } from './settings';
import type { PluginSettings, PromptPair } from './types';
const PROMPT_LANGUAGE_INSTRUCTIONS: Record<string, string> = {
auto: 'Write title, gist, and bullets in the main language of the source document.',
zh: '用中文输出 title、gist 和 bullets。',
en: 'Write title, gist, and bullets in English.',
ja: 'Write title, gist, and bullets in Japanese.',
ko: 'Write title, gist, and bullets in Korean.',
fr: 'Write title, gist, and bullets in French.',
de: 'Write title, gist, and bullets in German.',
es: 'Write title, gist, and bullets in Spanish.',
};
const PROMPT_SCHEMA_EXAMPLES: Record<string, string> = {
zh: `{"cards":[
{"title":"U 型收益曲线","anchor":"那谁又会被 AI 所受益?整体来看,它把整个分数变成了一分到七分","gist":"AI 生产力收益呈 U 型,两端受益最大、中间层塌陷","bullets":["最高薪岗位(软件管理)通过加速既有工作受益最大","最低薪岗位(外卖员、园艺工)用 AI 开副业创造新收入","中间层科学家、律师收益最少,部分因对 prompt 精度信任不足","全体均分 5.1/742% 报告收益模糊"]}
]}`,
en: `{"cards":[
{"title":"U-shaped gains","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI productivity gains form a U shape, with both ends benefiting most","bullets":["Top-paid software managers benefit by accelerating existing work","Low-paid workers use AI to create new side income","Middle-layer specialists gain less because prompt precision is hard to trust","Average reported benefit is 5.1/7, with 42% describing gains as unclear"]}
]}`,
ja: `{"cards":[
{"title":"U字型の利益","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AIによる生産性向上はU字型になり、両端の層がもっとも大きな恩恵を受ける","bullets":["高所得のソフトウェア管理職は既存業務を加速できるため大きく恩恵を受ける","低所得の労働者はAIを使って新しい副収入を作り出せる","中間層の専門職は、プロンプト精度への信頼が難しいため利益が小さい","平均利益は5.1/7で、42%が効果は不明確だと報告している"]}
]}`,
ko: `{"cards":[
{"title":"U자형 이득","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI 생산성 향상은 U자형을 보이며 양끝 집단이 가장 큰 혜택을 얻는다","bullets":["고소득 소프트웨어 관리자는 기존 업무를 더 빠르게 처리해 큰 이득을 얻는다","저소득 노동자는 AI로 새로운 부수입 기회를 만들 수 있다","중간층 전문가는 프롬프트 정확도를 신뢰하기 어려워 상대적으로 이득이 작다","평균 체감 이득은 5.1/7이며 42%는 효과가 불명확하다고 답했다"]}
]}`,
fr: `{"cards":[
{"title":"Gains en U","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"Les gains de productivité liés à l'AI forment une courbe en U, les deux extrémités en profitant le plus","bullets":["Les managers logiciels très rémunérés gagnent surtout en accélérant leur travail existant","Les travailleurs peu rémunérés utilisent l'AI pour créer de nouveaux revenus complémentaires","Les spécialistes intermédiaires gagnent moins, car la précision des prompts reste difficile à fiabiliser","Le bénéfice moyen déclaré est de 5,1/7, avec 42% de gains jugés peu clairs"]}
]}`,
de: `{"cards":[
{"title":"U-förmige Gewinne","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI-Produktivitätsgewinne bilden eine U-Form, bei der beide Enden am stärksten profitieren","bullets":["Hochbezahlte Softwaremanager profitieren, weil sie bestehende Arbeit beschleunigen","Geringverdienende nutzen AI, um neue Nebeneinnahmen zu schaffen","Spezialisten in der Mitte gewinnen weniger, weil präzise Prompts schwer zu vertrauen sind","Der gemeldete Durchschnittsnutzen liegt bei 5,1/7; 42% beschreiben die Gewinne als unklar"]}
]}`,
es: `{"cards":[
{"title":"Ganancias en forma de U","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"Las mejoras de productividad con AI forman una U: los extremos son quienes más se benefician","bullets":["Los gerentes de software mejor pagados se benefician al acelerar su trabajo existente","Los trabajadores con menores ingresos usan AI para crear nuevas fuentes de ingreso","Los especialistas intermedios ganan menos porque es difícil confiar en la precisión del prompt","El beneficio medio reportado es 5,1/7, con un 42% que describe las ganancias como poco claras"]}
]}`,
};
function usesEnglishPromptShell(language: string): boolean {
return language !== 'zh' && language !== 'auto';
}
export function promptLanguageInstruction(language: string): string {
if (language === 'en') return 'Write title, gist, and bullets in English.';
if (language === 'auto') return 'Write title, gist, and bullets in the main language of the source document.';
return '用中文输出 title、gist 和 bullets。';
return PROMPT_LANGUAGE_INSTRUCTIONS[language] || PROMPT_LANGUAGE_INSTRUCTIONS.zh;
}
export function promptSchemaExample(language: string): string {
if (language === 'en') {
return `{"cards":[
{"title":"U-shaped gains","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI productivity gains form a U shape, with both ends benefiting most","bullets":["Top-paid software managers benefit by accelerating existing work","Low-paid workers use AI to create new side income","Middle-layer specialists gain less because prompt precision is hard to trust","Average reported benefit is 5.1/7, with 42% describing gains as unclear"]}
]}`;
}
return `{"cards":[
{"title":"U 型收益曲线","anchor":"那谁又会被 AI 所受益?整体来看,它把整个分数变成了一分到七分","gist":"AI 生产力收益呈 U 型,两端受益最大、中间层塌陷","bullets":["最高薪岗位(软件管理)通过加速既有工作受益最大","最低薪岗位(外卖员、园艺工)用 AI 开副业创造新收入","中间层科学家、律师收益最少,部分因对 prompt 精度信任不足","全体均分 5.1/742% 报告收益模糊"]}
]}`;
return PROMPT_SCHEMA_EXAMPLES[language] || PROMPT_SCHEMA_EXAMPLES.zh;
}
export function renderPromptTemplate(template: string, vars: Record<string, string | number>): string {
@ -34,7 +64,7 @@ function defaultSystemPrompt(
schema: string,
example: string,
): string {
if (language === 'en') {
if (usesEnglishPromptShell(language)) {
return `You are a long-form reading summary assistant. After reading the full document, split it into ${minCards}-${maxCards} natural topic units. They do not need to match markdown headings; use a complete argument or topic as the unit, merging short sections and splitting long ones when needed.
Each card has one guiding sentence plus several bullets. Bullets carry details; gist is the lead-in.
@ -98,7 +128,7 @@ function systemPromptContract(
languageInstruction: string,
schema: string,
): string {
if (language === 'en') {
if (usesEnglishPromptShell(language)) {
return `Non-overridable output contract:
- Must output ${minCards}-${maxCards} cards.
- ${languageInstruction}
@ -125,7 +155,7 @@ export function buildPrompts(content: string, settings: PluginSettings): PromptP
const doc =
content.length > maxDocChars
? content.slice(0, maxDocChars) +
(promptLanguage === 'en' ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
(usesEnglishPromptShell(promptLanguage) ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
: content;
const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}';
@ -141,6 +171,8 @@ export function buildPrompts(content: string, settings: PluginSettings): PromptP
${contract}`
: defaultSystem;
const user = promptLanguage === 'en' ? `Source document:\n\n${doc}` : `以下是需要处理的文档全文:\n\n${doc}`;
const user = usesEnglishPromptShell(promptLanguage)
? `Source document:\n\n${doc}`
: `以下是需要处理的文档全文:\n\n${doc}`;
return { system, user };
}

View file

@ -15,14 +15,24 @@ const DEFAULT_CLI_TIMEOUT_MS = 300000;
const MIN_STREAMING_TIMEOUT_MS = 1000;
const MIN_CLI_TIMEOUT_MS = 1000;
export const PROMPT_LANGUAGES = {
auto: 'Auto-detect',
zh: '中文',
en: 'English',
auto: 'Auto-detect',
ja: '日本語',
ko: '한국어',
fr: 'Français',
de: 'Deutsch',
es: 'Español',
};
export const UI_LANGUAGES = {
auto: 'Auto',
zh: '中文',
en: 'English',
ja: '日本語',
ko: '한국어',
fr: 'Français',
de: 'Deutsch',
es: 'Español',
};
export const DEFAULT_SETTINGS: PluginSettings = {

View file

@ -28,16 +28,64 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'en' }), 'en');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'zh' }), 'zh');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'ja' }), 'ja');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'ko' }), 'ko');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'fr' }), 'fr');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'de' }), 'de');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'es' }), 'es');
assert.strictEqual(i18n.resolveUiLanguage(null), 'en');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'en');
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const setNavigatorLanguage = (language) => {
Object.defineProperty(globalThis, 'navigator', {
value: { language },
configurable: true,
});
};
try {
setNavigatorLanguage('ja-JP');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'ja');
setNavigatorLanguage('ko-KR');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'ko');
setNavigatorLanguage('fr-CA');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'fr');
setNavigatorLanguage('de_DE');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'de');
setNavigatorLanguage('es-MX');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'es');
setNavigatorLanguage('it-IT');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'en');
} finally {
if (originalNavigator) {
Object.defineProperty(globalThis, 'navigator', originalNavigator);
} else {
delete globalThis.navigator;
}
}
assert.ok(i18n.STRINGS.zh);
assert.ok(i18n.STRINGS.en);
const zhKeys = Object.keys(i18n.STRINGS.zh);
const enKeys = Object.keys(i18n.STRINGS.en);
assert.strictEqual(zhKeys.length, enKeys.length);
for (const key of zhKeys) {
assert.ok(i18n.STRINGS.en[key], `en missing key: ${key}`);
assert.ok(i18n.STRINGS.ja);
assert.ok(i18n.STRINGS.ko);
assert.ok(i18n.STRINGS.fr);
assert.ok(i18n.STRINGS.de);
assert.ok(i18n.STRINGS.es);
const supportedLocales = ['zh', 'en', 'ja', 'ko', 'fr', 'de', 'es'];
const enKeys = Object.keys(i18n.STRINGS.en).sort();
for (const locale of supportedLocales) {
const keys = Object.keys(i18n.STRINGS[locale]).sort();
assert.deepStrictEqual(keys, enKeys, `${locale} keys must match en`);
for (const key of enKeys) {
assert.ok(i18n.STRINGS[locale][key], `${locale} empty key: ${key}`);
}
}
for (const locale of ['ja', 'ko', 'fr', 'de', 'es']) {
const overrideKeys = Object.keys(i18n.LOCALE_OVERRIDES[locale]).sort();
assert.deepStrictEqual(overrideKeys, enKeys, `${locale} raw override keys must match en`);
for (const key of enKeys) {
assert.ok(i18n.LOCALE_OVERRIDES[locale][key], `${locale} empty raw override: ${key}`);
}
}
console.log('direct i18n tests passed');

View file

@ -13,8 +13,13 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
prompt.promptLanguageInstruction('auto'),
'Write title, gist, and bullets in the main language of the source document.',
);
assert.strictEqual(prompt.promptLanguageInstruction('ja'), 'Write title, gist, and bullets in Japanese.');
assert.strictEqual(prompt.promptLanguageInstruction('ko'), 'Write title, gist, and bullets in Korean.');
assert.strictEqual(prompt.promptLanguageInstruction('fr'), 'Write title, gist, and bullets in French.');
assert.strictEqual(prompt.promptLanguageInstruction('de'), 'Write title, gist, and bullets in German.');
assert.strictEqual(prompt.promptLanguageInstruction('es'), 'Write title, gist, and bullets in Spanish.');
assert.strictEqual(
prompt.promptLanguageInstruction('fr'),
prompt.promptLanguageInstruction('xx'),
'用中文输出 title、gist 和 bullets。',
'unknown language falls to zh',
);
@ -29,6 +34,11 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.ok(zhExample.includes('"cards"'), 'zh example is JSON-like');
assert.ok(prompt.promptSchemaExample('auto').includes('U 型收益曲线'), 'auto falls to zh example');
assert.ok(prompt.promptSchemaExample('ja').includes('U字型の利益'), 'ja example has expected title');
assert.ok(prompt.promptSchemaExample('ko').includes('U자형 이득'), 'ko example has expected title');
assert.ok(prompt.promptSchemaExample('fr').includes('Gains en U'), 'fr example has expected title');
assert.ok(prompt.promptSchemaExample('de').includes('U-förmige Gewinne'), 'de example has expected title');
assert.ok(prompt.promptSchemaExample('es').includes('Ganancias en forma de U'), 'es example has expected title');
// ── renderPromptTemplate ──
assert.strictEqual(prompt.renderPromptTemplate('Hello {name}!', { name: 'World' }), 'Hello World!');
@ -55,6 +65,19 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const autoResult = prompt.buildPrompts('content', { ...base, promptLanguage: 'auto' });
assert.ok(autoResult.system.includes('main language'), 'auto language instruction');
// ── buildPrompts: expanded output languages ──
for (const [language, expected] of [
['ja', 'Japanese'],
['ko', 'Korean'],
['fr', 'French'],
['de', 'German'],
['es', 'Spanish'],
]) {
const result = prompt.buildPrompts('content', { ...base, promptLanguage: language });
assert.ok(result.system.includes(expected), `${language} language instruction`);
assert.ok(result.user.includes('Source document:'), `${language} uses provider-facing source prefix`);
}
// ── buildPrompts: truncation ──
const longDoc = 'x'.repeat(25000);
const truncResult = prompt.buildPrompts(longDoc, { ...base, maxDocChars: 20000 });

View file

@ -4,6 +4,10 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
try {
const settings = await requireBundledModule('src/settings.ts');
const base = settings.DEFAULT_SETTINGS;
const supportedLanguageIds = ['auto', 'zh', 'en', 'ja', 'ko', 'fr', 'de', 'es'];
assert.deepStrictEqual(Object.keys(settings.PROMPT_LANGUAGES), supportedLanguageIds, 'prompt language order');
assert.deepStrictEqual(Object.keys(settings.UI_LANGUAGES), supportedLanguageIds, 'ui language order');
// ── stableStringify ──
assert.strictEqual(settings.stableStringify(42), '42', 'number');
@ -99,6 +103,19 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.strictEqual(normed.maxDocChars, base.maxDocChars, 'maxDocChars <1000 → default');
assert.strictEqual(normed.customSystemPrompt, '', 'non-string customSystemPrompt → empty string');
for (const language of supportedLanguageIds) {
assert.strictEqual(
settings.normalizeSettings({ ...base, uiLanguage: language }).uiLanguage,
language,
`normalizeSettings preserves uiLanguage ${language}`,
);
assert.strictEqual(
settings.normalizeSettings({ ...base, promptLanguage: language }).promptLanguage,
language,
`normalizeSettings preserves promptLanguage ${language}`,
);
}
// ── pruneCacheEntries edge cases ──
assert.deepStrictEqual(settings.pruneCacheEntries(null, 10), [], 'null cache');
assert.deepStrictEqual(settings.pruneCacheEntries({}, 10), [], 'empty cache');

View file

@ -2,12 +2,22 @@ const { assert, t } = require('./test-setup');
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'appTitle'), '对照阅读笔记', 'zh translation');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'appTitle'), 'Parallel Reader', 'en translation');
assert.strictEqual(t.translate({ uiLanguage: 'ja' }, 'settingTestBackendButton'), 'テスト');
assert.strictEqual(t.translate({ uiLanguage: 'ko' }, 'settingTestBackendButton'), '테스트');
assert.strictEqual(t.translate({ uiLanguage: 'fr' }, 'settingTestBackendButton'), 'Tester');
assert.strictEqual(t.translate({ uiLanguage: 'de' }, 'settingTestBackendButton'), 'Testen');
assert.strictEqual(t.translate({ uiLanguage: 'es' }, 'settingTestBackendButton'), 'Probar');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'nonexistent_key'), 'nonexistent_key', 'missing key returns key');
assert.strictEqual(
t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 5 }),
'Cleared 5 cache entries',
'variable interpolation',
);
assert.strictEqual(
t.translate({ uiLanguage: 'fr' }, 'cacheClearedAll', { count: 5 }),
'5 entrées de cache effacées',
'new locale interpolation',
);
assert.strictEqual(
t.translate({ uiLanguage: 'en' }, 'exported', { path: 'foo/bar.md' }),
'Exported → foo/bar.md',

View file

@ -21,5 +21,6 @@
"1.0.19": "1.8.7",
"1.0.20": "1.8.7",
"1.0.21": "1.8.7",
"1.0.22": "1.8.7"
"1.0.22": "1.8.7",
"1.0.23": "1.8.7"
}