fix: match notes via metadataCache so Properties edits don't orphan them

Obsidian's Properties editor re-serializes frontmatter (drops quotes,
rewrites inline lists to block style), which broke the exact-substring
video_id match and the addDimension regex — every later clip created a
permanent duplicate note. Cache misses (just-created notes) fall back
to a quote-tolerant scan; cached non-matches skip the file read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
liyachen 2026-07-18 17:51:40 -04:00
parent 25b0f5b029
commit 6f8c35e75e
6 changed files with 125 additions and 8 deletions

View file

@ -14,6 +14,8 @@ export interface VaultOps {
listMarkdownFiles(folderPath: string): string[];
read(filePath: string): Promise<string>;
modify(filePath: string, content: string): Promise<void>;
// Parsed frontmatter from Obsidian's metadataCache; null when not indexed yet.
getFrontmatter(filePath: string): Record<string, unknown> | 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;
}

View file

@ -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,

View file

@ -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`;
});
}

View file

@ -17,6 +17,7 @@ function makeVaultOps(): jest.Mocked<VaultOps> {
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([]);

View file

@ -13,6 +13,7 @@ function mockVaultOps(): VaultOps {
listMarkdownFiles: () => [],
read: async () => '',
modify: async () => {},
getFrontmatter: () => null,
};
}

View file

@ -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');