diff --git a/src/video-note.ts b/src/video-note.ts index c8b595a..fd2d28b 100644 --- a/src/video-note.ts +++ b/src/video-note.ts @@ -131,7 +131,7 @@ export function ensurePublished(content: string, published?: string | null): str return `${fm}\npublished: ${published}${content.slice(end)}`; } -interface ParsedSection { kind: SectionKind; startSeconds: number; text: string; } +interface ParsedSection { kind: SectionKind | null; startSeconds: number; text: string; } function kindOf(heading: string): SectionKind | null { // Substring match so an emoji prefix (## ๐ŸŽฌ ๅ†…ๅฎน) still resolves to its kind. @@ -185,7 +185,7 @@ function parseSections(body: string): { head: string; sections: ParsedSection[] let curHeading = ''; const flush = () => { if (cur) { - const kind = kindOf(curHeading) ?? 'motion'; + const kind = kindOf(curHeading); const m = curHeading.match(/(\d+)s/); sections.push({ kind, startSeconds: m ? parseInt(m[1], 10) : 0, text: cur.join('\n') }); } @@ -243,15 +243,20 @@ export function mergeSection(existing: string, section: NewSection): { content: } const incoming: ParsedSection = { kind: section.kind, startSeconds: section.startSeconds, text: section.text }; + // Unknown-kind sections (a user's hand-written heading) sort after every + // known kind, keeping their relative order via the stable sort below. + const rank = (k: SectionKind | null) => (k === null ? KINDS.length : KINDS.indexOf(k)); const all = [...sections, incoming].sort((a, b) => - KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind) || a.startSeconds - b.startSeconds, + rank(a.kind) - rank(b.kind) || (a.kind === null || b.kind === null ? 0 : a.startSeconds - b.startSeconds), ); const ordered = renumber(all); const newFrontmatter = addDimension(frontmatter, section.kind); const dims = KINDS.filter((k) => ordered.some((s) => s.kind === k)); const newHead = syncOverview(head, newFrontmatter, dims).replace(/\s+$/, ''); - const renderedSections = ordered.map((s) => stripTrailingRule(emojiHeading(s.text, s.kind))).join('\n\n---\n\n'); + const renderedSections = ordered + .map((s) => stripTrailingRule(s.kind === null ? s.text : emojiHeading(s.text, s.kind))) + .join('\n\n---\n\n'); const newBody = [newHead, '', renderedSections, ''].join('\n'); return { content: `${newFrontmatter}${newBody}`, skipped: false }; } diff --git a/tests/video-note.test.ts b/tests/video-note.test.ts index e2eabfd..7cc9e77 100644 --- a/tests/video-note.test.ts +++ b/tests/video-note.test.ts @@ -116,6 +116,21 @@ test('a ## line inside a user code fence is NOT treated as a section', () => { expect(r.content).toContain('## โœจ ๅŠจๆ•ˆ โ‘ก ยท 130sโ€“138s'); }); +test('a hand-written unknown heading survives a merge untouched, placed after known sections', () => { + let c = buildAnchor(meta); + c = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 45, end: 52, frameNames: ['k.png'] })).content; + c = c + '\n\n---\n\n## ๆˆ‘็š„ๅˆ†ๆž\n\n่ฟ™ๆ˜ฏๆˆ‘็š„ๆƒณๆณ•\n'; + const r = mergeSection(c, keyframeSection({ url: meta.videoUrl, platform: 'youtube', start: 130, end: 138, frameNames: ['k2.png'] })); + expect(r.content).toContain('## ๆˆ‘็š„ๅˆ†ๆž'); + expect(r.content).toContain('่ฟ™ๆ˜ฏๆˆ‘็š„ๆƒณๆณ•'); + expect(r.content.indexOf('## ๆˆ‘็š„ๅˆ†ๆž')).toBeGreaterThan(r.content.lastIndexOf('## โœจ ๅŠจๆ•ˆ')); + + const r2 = mergeSection(r.content, screenshotSection(['s.png'])); + expect((r2.content.match(/## ๆˆ‘็š„ๅˆ†ๆž/g) || []).length).toBe(1); + expect(r2.content).toContain('่ฟ™ๆ˜ฏๆˆ‘็š„ๆƒณๆณ•'); + expect(r2.content.indexOf('## ๆˆ‘็š„ๅˆ†ๆž')).toBeGreaterThan(r2.content.lastIndexOf('## ๐Ÿ“ธ ๆˆชๅ›พ')); +}); + 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');