feat: i18n module with bundled en/zh locale files

This commit is contained in:
liyachen 2026-07-13 19:45:26 -04:00
parent 38e02304f7
commit d8bf73ac7f
5 changed files with 157 additions and 0 deletions

28
src/i18n.ts Normal file
View file

@ -0,0 +1,28 @@
import en from './locales/en.json';
import zh from './locales/zh.json';
export type Language = 'en' | 'zh';
export type LocaleKey = keyof typeof en;
// Typed as Record<LocaleKey, string> so a key missing from zh.json is a compile error.
const locales: Record<Language, Record<LocaleKey, string>> = { en, zh };
let current: Language = 'en';
export function setLanguage(lang: Language): void {
current = lang;
}
export function t(key: LocaleKey, vars?: Record<string, string | number>): string {
let s = locales[current][key];
if (vars) {
for (const [k, v] of Object.entries(vars)) s = s.split(`{${k}}`).join(String(v));
}
return s;
}
// Every language's value for a key — parsing must recognize notes written
// under any language setting, not just the current one.
export function variants(key: LocaleKey): string[] {
return (Object.keys(locales) as Language[]).map((l) => locales[l][key]);
}

45
src/locales/en.json Normal file
View file

@ -0,0 +1,45 @@
{
"settings.language": "Language",
"settings.storageHeading": "Storage locations",
"settings.videoNotesFolder.name": "Video notes folder",
"settings.videoNotesFolder.desc": "One note per video: cover, hook, and keyframe clips all merge into the same note. Default: Clips/Videos",
"settings.coverFolder.name": "Cover images folder",
"settings.coverFolder.desc": "Video cover images (<video ID>.webp). Default: Clips/Videos/covers",
"settings.framesFolder.name": "Frame images folder",
"settings.framesFolder.desc": "Frames extracted by hook / keyframe clips. Default: Clips/Videos/frames",
"settings.screenshotFolder.name": "Screenshots folder",
"settings.screenshotFolder.desc": "Webpage screenshots become standalone notes stored here; images go into its frames/ subfolder. Default: Clips/Screenshots",
"settings.advancedHeading": "Advanced",
"settings.httpEnable.name": "Enable HTTP server",
"settings.httpEnable.desc": "Receives content sent by the Chrome extension via POST /clip",
"settings.port.name": "Port",
"settings.port.desc": "Default 17183. Only change it if the port is taken; after changing, set the same value on the extension's welcome page (Advanced → Port), or the two sides will disconnect. Restart Obsidian afterwards.",
"settings.maxFrames.name": "Max frames",
"settings.maxFrames.desc": "Maximum frames kept per hook / keyframe clip (120). Default 5.",
"settings.sop.thumbnail": "Cover SOP path",
"settings.sop.screenshot": "Screenshot SOP path",
"settings.sop.hook": "Hook SOP path",
"settings.sop.keyframe": "Keyframe SOP path",
"settings.sop.desc": "Leave empty for material-only mode (no analysis prompt). Absolute path to a markdown file inside the vault.",
"notice.savedTo": "Saved to {folder}\nWant a different location? Settings → Vault Autopilot → Storage locations",
"notice.portInUse": "Vault Autopilot: port {port} is already in use. Quit the program using it, or set the same new port in both the plugin settings and the extension settings.",
"notice.sectionExists": "\"{section}\" already exists — not overwritten. To redo it, delete that section first, then clip again.",
"error.screenshotFolderNotConfigured": "Screenshots folder not configured: set it in Settings → Vault Autopilot → Storage locations → Screenshots folder.",
"error.videoFolderNotConfigured": "Video notes folder or cover images folder not configured: set them in Settings → Vault Autopilot → Storage locations.",
"note.heading.cover": "Cover & Title",
"note.heading.content": "Content",
"note.heading.motion": "Motion",
"note.heading.screenshot": "Screenshots",
"note.transcript": "Transcript",
"note.source": "Source: {url}",
"note.screenshotCallout": "Screenshots",
"note.notesHeading": "Notes",
"note.videoFallback": "Video",
"note.openOriginal": "▶ Open original video",
"note.sopCalloutTitle": "Analysis prompt",
"note.sopDone": "When done:",
"note.sopStep1": "Analysis written into the note's sections",
"note.sopStep2": "Delete this entire prompt block",
"note.framesCalloutTitle": "Frames for analysis",
"note.framesChecklist": "Complete the analysis per the SOP and fill in the sections"
}

45
src/locales/zh.json Normal file
View file

@ -0,0 +1,45 @@
{
"settings.language": "语言",
"settings.storageHeading": "存储位置",
"settings.videoNotesFolder.name": "视频笔记文件夹",
"settings.videoNotesFolder.desc": "一个视频一条笔记封面、Hook、关键帧都写进同一条。默认 Clips/Videos",
"settings.coverFolder.name": "封面图片文件夹",
"settings.coverFolder.desc": "视频封面图(<视频ID>.webp。默认 Clips/Videos/covers",
"settings.framesFolder.name": "帧图片文件夹",
"settings.framesFolder.desc": "Hook / 关键帧抽出的帧图。默认 Clips/Videos/frames",
"settings.screenshotFolder.name": "截图文件夹",
"settings.screenshotFolder.desc": "普通网页截图独立成笔记,存在这里;图片自动放入其 frames/ 子文件夹。默认 Clips/Screenshots",
"settings.advancedHeading": "高级",
"settings.httpEnable.name": "启用 HTTP 服务",
"settings.httpEnable.desc": "接收 Chrome 扩展通过 POST /clip 发来的内容",
"settings.port.name": "端口",
"settings.port.desc": "默认 17183。仅当端口被占用时才需要改改完必须在扩展的引导页高级 → 端口)改成同一个值,否则两边会断开。改后重启 Obsidian。",
"settings.maxFrames.name": "抽帧数量上限",
"settings.maxFrames.desc": "Hook / 关键帧模式最多保存几帧120。默认 5。",
"settings.sop.thumbnail": "封面 SOP 路径",
"settings.sop.screenshot": "截图 SOP 路径",
"settings.sop.hook": "Hook SOP 路径",
"settings.sop.keyframe": "关键帧 SOP 路径",
"settings.sop.desc": "留空 = 纯素材模式(不附带分析提示)。填 vault 内 markdown 文件的绝对路径。",
"notice.savedTo": "已存到 {folder}\n想换位置设置 → Vault Autopilot → 存储位置",
"notice.portInUse": "Vault Autopilot端口 {port} 被占用。请关闭占用它的程序;或在插件设置和扩展设置两处改成同一个新端口。",
"notice.sectionExists": "「{section}」已存在,未覆盖。想重做请先删掉该小节再点。",
"error.screenshotFolderNotConfigured": "截图文件夹未配置:请在 设置 → Vault Autopilot → 存储位置 → 截图文件夹 填写。",
"error.videoFolderNotConfigured": "视频笔记文件夹或封面图片文件夹未配置:请在 设置 → Vault Autopilot → 存储位置 填写。",
"note.heading.cover": "封面标题",
"note.heading.content": "内容",
"note.heading.motion": "动效",
"note.heading.screenshot": "截图",
"note.transcript": "字幕",
"note.source": "来源:{url}",
"note.screenshotCallout": "截图",
"note.notesHeading": "笔记",
"note.videoFallback": "视频",
"note.openOriginal": "▶ 跳转原视频",
"note.sopCalloutTitle": "分析提示",
"note.sopDone": "完成后执行:",
"note.sopStep1": "分析已写入笔记各章节",
"note.sopStep2": "删除此整个提示块",
"note.framesCalloutTitle": "分析用帧",
"note.framesChecklist": "按 SOP 完成分析,填入各章节"
}

37
tests/i18n.test.ts Normal file
View file

@ -0,0 +1,37 @@
import { t, setLanguage, variants } from '../src/i18n';
import en from '../src/locales/en.json';
import zh from '../src/locales/zh.json';
afterEach(() => setLanguage('en'));
test('en and zh have identical key sets', () => {
expect(Object.keys(zh).sort()).toEqual(Object.keys(en).sort());
});
test('t returns English by default', () => {
expect(t('settings.storageHeading')).toBe('Storage locations');
});
test('t returns Chinese after setLanguage', () => {
setLanguage('zh');
expect(t('settings.storageHeading')).toBe('存储位置');
});
test('t interpolates {vars}', () => {
expect(t('notice.savedTo', { folder: 'Clips/Videos' })).toContain('Clips/Videos');
expect(t('notice.savedTo', { folder: 'Clips/Videos' })).not.toContain('{folder}');
});
test('variants returns the value from every language', () => {
expect(variants('note.heading.cover').sort()).toEqual(['Cover & Title', '封面标题'].sort());
});
test('heading labels contain no regex metacharacters', () => {
// renumber() builds a RegExp from these labels — keep them literal.
const headingKeys = ['note.heading.cover', 'note.heading.content', 'note.heading.motion', 'note.heading.screenshot'] as const;
for (const key of headingKeys) {
for (const label of variants(key)) {
expect(label).not.toMatch(/[\\^$.*+?()[\]{}|]/);
}
}
});

View file

@ -6,6 +6,8 @@
"module": "CommonJS",
"target": "ES2018",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"lib": ["ES2018", "DOM"],
"types": ["node", "jest"]
},