From abd47cb07974ae250bfbaec14799ff6f0a505b1a Mon Sep 17 00:00:00 2001 From: liyachen Date: Fri, 17 Jul 2026 18:03:36 -0400 Subject: [PATCH] feat(clip): carry published_at into note frontmatter with upsert backfill Co-Authored-By: Claude Fable 5 --- src/clip-router.ts | 11 ++++++++--- src/server.ts | 1 + src/video-note.ts | 14 ++++++++++++++ tests/video-note.test.ts | 31 ++++++++++++++++++++++++++++++- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/clip-router.ts b/src/clip-router.ts index 20b02ef..add649e 100644 --- a/src/clip-router.ts +++ b/src/clip-router.ts @@ -1,7 +1,7 @@ import { ClipPayload, HookPayload, KeyframePayload, ScreenshotPayload, ThumbnailPayload } from './server'; import { ClipRule, PluginSettings, ScreenshotClipRule, ThumbnailClipRule } from './types'; import { sanitize, buildVideoEmbed, extractVideoId, detectPlatform, videoKey } from './util'; -import { buildAnchor, mergeSection, coverSection, hookSection, keyframeSection, screenshotSection, VideoNoteMeta, NewSection, headingLabel, sopBlock } from './video-note'; +import { buildAnchor, ensurePublished, mergeSection, coverSection, hookSection, keyframeSection, screenshotSection, VideoNoteMeta, NewSection, headingLabel, sopBlock } from './video-note'; import { t } from './i18n'; export interface VaultOps { @@ -54,8 +54,12 @@ async function upsertVideoNote( const existing = meta.videoId ? await findNoteByVideoId(meta.videoId, folder, vaultOps) : null; if (existing) { const { content, skipped } = mergeSection(existing.content, section); - if (skipped) return { notePath: existing.path, notice: t('notice.sectionExists', { section: headingLabel(section.kind) }) }; - await vaultOps.modify(existing.path, content); + if (skipped) { + const patched = ensurePublished(existing.content, meta.published); + if (patched !== existing.content) await vaultOps.modify(existing.path, patched); + return { notePath: existing.path, notice: t('notice.sectionExists', { section: headingLabel(section.kind) }) }; + } + await vaultOps.modify(existing.path, ensurePublished(content, meta.published)); return { notePath: existing.path }; } const { content } = mergeSection(buildAnchor(meta), section); @@ -198,6 +202,7 @@ async function handleThumbnail( videoUrl: payload.video_url, title: payload.title, channel: payload.channel, + published: payload.published_at, }; return upsertVideoNote(meta, section, vaultOps, rule.outputFolder); } diff --git a/src/server.ts b/src/server.ts index 9c25055..1522415 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,6 +45,7 @@ export type ThumbnailPayload = { channel_handle?: string | null; views?: string | null; captured_at: string; + published_at?: string; }; export type ClipPayload = diff --git a/src/video-note.ts b/src/video-note.ts index 480c53c..541807d 100644 --- a/src/video-note.ts +++ b/src/video-note.ts @@ -33,6 +33,7 @@ export interface VideoNoteMeta { videoUrl: string; title: string; channel?: string | null; + published?: string | null; } export interface NewSection { @@ -112,11 +113,24 @@ export function buildAnchor(meta: VideoNoteMeta): string { `video_id: "${meta.videoId}"`, `video_url: "${meta.videoUrl}"`, `title: "${meta.title}"`, ...(meta.channel ? [`channel: "${meta.channel}"`] : []), + ...(meta.published ? [`published: ${meta.published}`] : []), `dimensions: []`, `analyzed_at: ${today}`, `tags: []`, `depth: normal`, `---`, ].join('\n'); return `${fm}\n\n# ${meta.title}\n`; } +// Backfill path: notes created by hook/keyframe (or an older version) have no +// `published` — patch it into the frontmatter the next time a thumbnail clip +// for the same video arrives with one. +export function ensurePublished(content: string, published?: string | null): string { + if (!published || !content.startsWith('---\n')) return content; + const end = content.indexOf('\n---', 4); + if (end === -1) return content; + const fm = content.slice(0, end); + if (/^published:/m.test(fm)) return content; + return `${fm}\npublished: ${published}${content.slice(end)}`; +} + interface ParsedSection { kind: SectionKind; startSeconds: number; text: string; } function kindOf(heading: string): SectionKind | null { diff --git a/tests/video-note.test.ts b/tests/video-note.test.ts index 4be97b8..70dafb3 100644 --- a/tests/video-note.test.ts +++ b/tests/video-note.test.ts @@ -1,4 +1,4 @@ -import { buildAnchor, mergeSection, coverSection, hookSection, keyframeSection, screenshotSection, VideoNoteMeta } from '../src/video-note'; +import { buildAnchor, ensurePublished, mergeSection, coverSection, hookSection, keyframeSection, screenshotSection, VideoNoteMeta } from '../src/video-note'; import { setLanguage } from '../src/i18n'; beforeEach(() => setLanguage('zh')); @@ -183,3 +183,32 @@ describe('bilingual headings', () => { expect(note).not.toContain('dimensions: [动效, Motion]'); }); }); + +describe('published frontmatter', () => { + const base = { platform: 'youtube', videoId: 'x', videoUrl: 'https://y/x', title: 'T' }; + + test('buildAnchor writes published when meta carries it', () => { + expect(buildAnchor({ ...base, published: '2026-07-10' })).toContain('published: 2026-07-10'); + }); + + test('buildAnchor omits published when absent', () => { + expect(buildAnchor(base)).not.toContain('published:'); + }); + + test('ensurePublished inserts into existing frontmatter', () => { + const content = '---\ntype: video\ntitle: "T"\n---\n\n# T\n'; + const out = ensurePublished(content, '2026-07-10'); + expect(out).toContain('published: 2026-07-10'); + expect(out.indexOf('published:')).toBeLessThan(out.indexOf('\n---\n')); + }); + + test('ensurePublished is a no-op when already present', () => { + const content = '---\ntype: video\npublished: 2026-01-01\n---\n\n# T\n'; + expect(ensurePublished(content, '2026-07-10')).toBe(content); + }); + + test('ensurePublished is a no-op without a date or frontmatter', () => { + expect(ensurePublished('---\ntype: video\n---\n', undefined)).toBe('---\ntype: video\n---\n'); + expect(ensurePublished('no frontmatter', '2026-07-10')).toBe('no frontmatter'); + }); +});