feat(clip): carry published_at into note frontmatter with upsert backfill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
liyachen 2026-07-17 18:03:36 -04:00
parent 9d0a8b9a28
commit abd47cb079
4 changed files with 53 additions and 4 deletions

View file

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

View file

@ -45,6 +45,7 @@ export type ThumbnailPayload = {
channel_handle?: string | null;
views?: string | null;
captured_at: string;
published_at?: string;
};
export type ClipPayload =

View file

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

View file

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