mirror of
https://github.com/echore/vault-autopilot.git
synced 2026-07-22 08:34:00 +00:00
feat: i18n module with bundled en/zh locale files
This commit is contained in:
parent
38e02304f7
commit
d8bf73ac7f
5 changed files with 157 additions and 0 deletions
28
src/i18n.ts
Normal file
28
src/i18n.ts
Normal 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
45
src/locales/en.json
Normal 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 (1–20). 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
45
src/locales/zh.json
Normal 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 / 关键帧模式最多保存几帧(1–20)。默认 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
37
tests/i18n.test.ts
Normal 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(/[\\^$.*+?()[\]{}|]/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
"module": "CommonJS",
|
||||
"target": "ES2018",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["ES2018", "DOM"],
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue