diff --git a/src/clip-router.ts b/src/clip-router.ts index 584fe84..fabfb6a 100644 --- a/src/clip-router.ts +++ b/src/clip-router.ts @@ -14,6 +14,8 @@ export interface VaultOps { listMarkdownFiles(folderPath: string): string[]; read(filePath: string): Promise; modify(filePath: string, content: string): Promise; + // Parsed frontmatter from Obsidian's metadataCache; null when not indexed yet. + getFrontmatter(filePath: string): Record | null; } export async function routeClip( @@ -163,15 +165,30 @@ function sampleFrames(frames: string[], max: number): string[] { return Array.from({ length: max }, (_, i) => frames[Math.floor(i * step)]); } +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + async function findNoteByVideoId( videoId: string, folder: string, vaultOps: VaultOps, ): Promise<{ path: string; content: string } | null> { const files = vaultOps.listMarkdownFiles(folder); + // Obsidian's Properties editor re-serializes frontmatter and drops quotes, so + // an exact-substring match on `video_id: "x"` orphans the note forever. The + // metadataCache compares parsed values (quote-agnostic) without reading file + // contents; files the cache hasn't indexed yet (just-created notes) fall back + // to a quote-tolerant scan. + const pattern = new RegExp(`^video_id:\\s*"?${escapeRegExp(videoId)}"?\\s*$`, 'm'); for (const filePath of files) { + const fm = vaultOps.getFrontmatter(filePath); + if (fm) { + if (String(fm.video_id ?? '') === videoId) return { path: filePath, content: await vaultOps.read(filePath) }; + continue; + } const content = await vaultOps.read(filePath); - if (content.includes(`video_id: "${videoId}"`)) return { path: filePath, content }; + if (pattern.test(content)) return { path: filePath, content }; } return null; } diff --git a/src/main.ts b/src/main.ts index c3e12fb..df4edc1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -157,6 +157,11 @@ export default class VaultAutopilotPlugin extends Plugin { if (!(file instanceof TFile)) throw new Error(`File not found: ${filePath}`); await this.app.vault.modify(file, content); }, + getFrontmatter: (filePath) => { + const file = this.app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) return null; + return this.app.metadataCache.getFileCache(file)?.frontmatter ?? null; + }, }; this.server = createServer( port, diff --git a/src/video-note.ts b/src/video-note.ts index fd2d28b..23d266e 100644 --- a/src/video-note.ts +++ b/src/video-note.ts @@ -208,14 +208,28 @@ function parseSections(body: string): { head: string; sections: ParsedSection[] } function addDimension(frontmatter: string, kind: SectionKind): string { - return frontmatter.replace(/^(dimensions:\s*\[)([^\]]*)(\])/m, (_, open, inner, close) => { - const dims = inner.split(',').map((d: string) => d.trim()).filter(Boolean); - // Dedupe by kind, not by string — a zh note must not gain a second - // entry for the same dimension when a section is appended in en. - if (!dims.some((d: string) => labelToKind(d) === kind)) dims.push(headingLabel(kind)); + // Dedupe by kind, not by string — a zh note must not gain a second + // entry for the same dimension when a section is appended in en. + const unquote = (d: string) => d.replace(/^"(.*)"$/, '$1'); + const merged = (dims: string[]): string[] => { + if (!dims.some((d) => labelToKind(d) === kind)) dims.push(headingLabel(kind)); const rank = (d: string) => { const k = labelToKind(d); return k ? KINDS.indexOf(k) : -1; }; - dims.sort((a: string, b: string) => rank(a) - rank(b)); - return `${open}${dims.join(', ')}${close}`; + return dims.sort((a, b) => rank(a) - rank(b)); + }; + const inline = /^(dimensions:\s*\[)([^\]]*)(\])/m; + if (inline.test(frontmatter)) { + return frontmatter.replace(inline, (_, open, inner, close) => { + const dims = merged(inner.split(',').map((d: string) => unquote(d.trim())).filter(Boolean)); + return `${open}${dims.join(', ')}${close}`; + }); + } + // Obsidian's Properties editor rewrites inline lists to block style (quoting + // items it considers special); keep that shape when appending. + return frontmatter.replace(/^dimensions:\n((?:[ \t]+- [^\n]*(?:\n|$))*)/m, (_, items: string) => { + const dims = merged( + items.split('\n').map((l) => unquote(l.replace(/^[ \t]+- /, '').trim())).filter(Boolean), + ); + return `dimensions:\n${dims.map((d) => ` - ${d}`).join('\n')}\n`; }); } diff --git a/tests/clip-router.test.ts b/tests/clip-router.test.ts index 43fd987..131c4b6 100644 --- a/tests/clip-router.test.ts +++ b/tests/clip-router.test.ts @@ -17,6 +17,7 @@ function makeVaultOps(): jest.Mocked { listMarkdownFiles: jest.fn().mockReturnValue([]), read: jest.fn().mockResolvedValue(''), modify: jest.fn().mockResolvedValue(undefined), + getFrontmatter: jest.fn().mockReturnValue(null), }; } @@ -309,6 +310,71 @@ describe('routeClip — append to existing Great Videos note', () => { expect(result.notePath).toBe('Content Creation/Great Videos/note.md'); }); + test('finds a note whose frontmatter was re-serialized by Obsidian (unquoted video_id)', async () => { + const vaultOps = makeVaultOps(); + (vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/note.md']); + (vaultOps.read as jest.Mock).mockResolvedValue(existingNote.replace('video_id: "abc123"', 'video_id: abc123')); + const payload: ClipPayload = { + mode: 'hook', + frames: [Buffer.from('f1').toString('base64')], + video_title: 'My Video', + url: 'https://www.youtube.com/watch?v=abc123', + captured_at: '2026-05-31T00:00:00Z', + }; + await routeClip(payload, { ...clipRules, hook: { ...hookClipRule, sopPath: '' } }, vaultOps); + expect(vaultOps.modify).toHaveBeenCalled(); + expect(vaultOps.create).not.toHaveBeenCalled(); + }); + + test('finds a note via metadataCache frontmatter without a content scan', async () => { + const vaultOps = makeVaultOps(); + (vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/note.md']); + (vaultOps.getFrontmatter as jest.Mock).mockReturnValue({ video_id: 'abc123' }); + (vaultOps.read as jest.Mock).mockResolvedValue(existingNote); + const payload: ClipPayload = { + mode: 'hook', + frames: [Buffer.from('f1').toString('base64')], + video_title: 'My Video', + url: 'https://www.youtube.com/watch?v=abc123', + captured_at: '2026-05-31T00:00:00Z', + }; + await routeClip(payload, { ...clipRules, hook: { ...hookClipRule, sopPath: '' } }, vaultOps); + expect(vaultOps.modify).toHaveBeenCalled(); + expect(vaultOps.create).not.toHaveBeenCalled(); + }); + + test('cached non-matching frontmatter skips the file without reading it', async () => { + const vaultOps = makeVaultOps(); + (vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/other.md']); + (vaultOps.getFrontmatter as jest.Mock).mockReturnValue({ video_id: 'zzz999' }); + const payload: ClipPayload = { + mode: 'hook', + frames: [Buffer.from('f1').toString('base64')], + video_title: 'My Video', + url: 'https://www.youtube.com/watch?v=abc123', + captured_at: '2026-05-31T00:00:00Z', + }; + await routeClip(payload, { ...clipRules, hook: { ...hookClipRule, sopPath: '' } }, vaultOps); + expect(vaultOps.read).not.toHaveBeenCalled(); + expect(vaultOps.create).toHaveBeenCalled(); + }); + + test('URL-based video keys with regex metacharacters match without crashing', async () => { + const vaultOps = makeVaultOps(); + (vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue(['Content Creation/Great Videos/tweet.md']); + (vaultOps.read as jest.Mock).mockResolvedValue(existingNote.replace('video_id: "abc123"', 'video_id: https://x.com/u/status/123')); + const payload: ClipPayload = { + mode: 'hook', + frames: [Buffer.from('f1').toString('base64')], + video_title: 'Tweet Video', + url: 'https://x.com/u/status/123?s=20', + captured_at: '2026-05-31T00:00:00Z', + }; + await routeClip(payload, { ...clipRules, hook: { ...hookClipRule, sopPath: '' } }, vaultOps); + expect(vaultOps.modify).toHaveBeenCalled(); + expect(vaultOps.create).not.toHaveBeenCalled(); + }); + test('no existing note: creates new note in Great Videos with ## 🎬 内容 section', async () => { const vaultOps = makeVaultOps(); (vaultOps.listMarkdownFiles as jest.Mock).mockReturnValue([]); diff --git a/tests/contract.test.ts b/tests/contract.test.ts index 5f5d540..8c4b72a 100644 --- a/tests/contract.test.ts +++ b/tests/contract.test.ts @@ -13,6 +13,7 @@ function mockVaultOps(): VaultOps { listMarkdownFiles: () => [], read: async () => '', modify: async () => {}, + getFrontmatter: () => null, }; } diff --git a/tests/video-note.test.ts b/tests/video-note.test.ts index 7cc9e77..93920cb 100644 --- a/tests/video-note.test.ts +++ b/tests/video-note.test.ts @@ -131,6 +131,20 @@ test('a hand-written unknown heading survives a merge untouched, placed after kn expect(r2.content.indexOf('## 我的分析')).toBeGreaterThan(r2.content.lastIndexOf('## 📸 截图')); }); +test('dimensions survive Obsidian Properties re-serialization to block-style YAML', () => { + // Obsidian's Properties editor rewrites `dimensions: [内容]` to a block list + // (quoting items it considers special). A later merge must still dedupe/append. + const reserialized = `---\ntype: video\nvideo_id: abc123\nvideo_url: https://www.youtube.com/watch?v=abc123\ntitle: Bee\ndimensions:\n - "内容"\nanalyzed_at: 2026-07-01\n---\n\n# Bee\n\n## 🎬 内容\n\nbody\n`; + const r = mergeSection(reserialized, keyframeSection({ url: 'https://www.youtube.com/watch?v=abc123', platform: 'youtube', start: 45, end: 52, frameNames: ['k.png'] })); + const fm = r.content.match(/^---\n[\s\S]*?\n---/)![0]; + expect(fm).toMatch(/dimensions:[\s\S]*内容/); + expect(fm).toMatch(/dimensions:[\s\S]*动效/); + + const again = mergeSection(r.content, keyframeSection({ url: 'https://www.youtube.com/watch?v=abc123', platform: 'youtube', start: 100, end: 110, frameNames: ['k2.png'] })); + const fm2 = again.content.match(/^---\n[\s\S]*?\n---/)![0]; + expect((fm2.match(/动效/g) || []).length).toBe(1); +}); + test('hook section embeds the whole video from start (no end) + frames + 字幕', () => { const s = hookSection({ url: meta.videoUrl, platform: 'youtube', endSeconds: 15, frameNames: ['f1.png'], transcript: 'hello' }); expect(s.kind).toBe('content');