mirror of
https://github.com/echore/vault-autopilot.git
synced 2026-07-22 08:34:00 +00:00
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:
parent
25b0f5b029
commit
6f8c35e75e
6 changed files with 125 additions and 8 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ function mockVaultOps(): VaultOps {
|
|||
listMarkdownFiles: () => [],
|
||||
read: async () => '',
|
||||
modify: async () => {},
|
||||
getFrontmatter: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue