diff --git a/packages/obsidian/src/watchlist/SyncEngine.ts b/packages/obsidian/src/watchlist/SyncEngine.ts index 34025d5..c5462b7 100644 --- a/packages/obsidian/src/watchlist/SyncEngine.ts +++ b/packages/obsidian/src/watchlist/SyncEngine.ts @@ -1,4 +1,4 @@ -import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; +import { parseNote, extractMyNotes, extractCustomSections, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; import { buildRecord } from 'packages/obsidian/src/watchlist/build'; import { renderNote } from 'packages/obsidian/src/watchlist/render'; @@ -80,7 +80,7 @@ export async function syncFolder(deps: SyncDeps, opts: SyncOptions = {}): Promis } const detail: any = await withRateLimitRetry(() => deps.fetchDetail(ref.tmdbId, ref.isMovie), deps.sleep); const record = buildRecord(detail, ref.isMovie, frontmatter); - const rendered = renderNote(record, extractMyNotes(body)); + const rendered = renderNote(record, extractMyNotes(body), extractCustomSections(body)); report.synced++; if (strip(frontmatter['watch_status']) === 'Watched' && record.watchStatus === 'Unwatched') { report.flipped.push(note.path); diff --git a/packages/obsidian/src/watchlist/parse.ts b/packages/obsidian/src/watchlist/parse.ts index 041e484..401a281 100644 --- a/packages/obsidian/src/watchlist/parse.ts +++ b/packages/obsidian/src/watchlist/parse.ts @@ -22,6 +22,37 @@ export function extractMyNotes(body: string): string { return m ? m[1].trim() : ''; } +const OWNED_HEADINGS = new Set(['Synopsis', 'Cast', 'Links', 'My Notes']); + +export interface CustomSection { + heading: string; + content: string; +} + +export function extractCustomSections(body: string): CustomSection[] { + const lines = (body ?? '').split('\n'); + const sections: CustomSection[] = []; + let heading: string | null = null; + let buf: string[] = []; + const flush = (): void => { + if (heading === null) return; + const content = buf.join('\n').replace(/(\n\s*)+$/, ''); + if (!OWNED_HEADINGS.has(heading)) sections.push({ heading, content }); + }; + for (const line of lines) { + const m = /^##\s+(.*)$/.exec(line); + if (m) { + flush(); + heading = m[1].trim(); + buf = []; + } else if (heading !== null) { + buf.push(line); + } + } + flush(); + return sections; +} + export function stripQuotes(s: string | undefined): string { return (s ?? '').trim().replace(/^"|"$/g, ''); } diff --git a/packages/obsidian/src/watchlist/render.ts b/packages/obsidian/src/watchlist/render.ts index 1d63e51..186a50d 100644 --- a/packages/obsidian/src/watchlist/render.ts +++ b/packages/obsidian/src/watchlist/render.ts @@ -1,9 +1,10 @@ import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; +import type { CustomSection } from 'packages/obsidian/src/watchlist/parse'; const CATEGORY_TAG: Record = { Movie: 'movie', Series: 'series', Anime: 'anime' }; -export function renderNote(r: WatchlistRecord, myNotes: string): string { +export function renderNote(r: WatchlistRecord, myNotes: string, customSections: CustomSection[] = []): string { const tag = CATEGORY_TAG[r.category]; const stars = r.ratingStars; const fm = [ @@ -67,6 +68,7 @@ export function renderNote(r: WatchlistRecord, myNotes: string): string { if (r.homepage) links.push(`- [Homepage](${r.homepage})`); if (r.notionUrl) links.push(`- [Original Notion entry](${r.notionUrl})`); if (links.length) b.push('## Links', ...links, ''); + for (const s of customSections) b.push(`## ${s.heading}`, s.content, ''); b.push('## My Notes', '', myNotes); if (myNotes) b.push(''); return fm.join('\n') + '\n' + b.join('\n'); diff --git a/tests/watchlist-parse.test.ts b/tests/watchlist-parse.test.ts index 27c0f70..df0cc24 100644 --- a/tests/watchlist-parse.test.ts +++ b/tests/watchlist-parse.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; +import { parseNote, extractMyNotes, noteTmdbRef, extractCustomSections } from 'packages/obsidian/src/watchlist/parse'; const NOTE = `--- type: watchlist_item @@ -55,3 +55,32 @@ describe('noteTmdbRef', () => { expect(noteTmdbRef({ type: 'list' })).toBeNull(); }); }); + +describe('extractCustomSections', () => { + test('extracts non-owned section', () => { + const body = `\n# Loki\n\n## Links\n- [IMDb](x)\n\n## Collection\nPart of [[Movies]]\n\n## My Notes\n\ngreat finale\n`; + expect(extractCustomSections(body)).toEqual([{ heading: 'Collection', content: 'Part of [[Movies]]' }]); + }); + test('owned headings excluded (Synopsis, Cast, Links, My Notes)', () => { + const body = `\n## Synopsis\nblah\n\n## Cast\nA, B\n\n## Links\n- x\n\n## My Notes\n\nnote\n`; + expect(extractCustomSections(body)).toEqual([]); + }); + test('two custom sections keep order', () => { + const body = `\n## Collection\nPart of [[Movies]]\n\n## Rewatch Log\n- 2024-01-01\n- 2025-02-02\n\n## My Notes\n\nnote\n`; + expect(extractCustomSections(body)).toEqual([ + { heading: 'Collection', content: 'Part of [[Movies]]' }, + { heading: 'Rewatch Log', content: '- 2024-01-01\n- 2025-02-02' }, + ]); + }); + test('preserves internal blank lines, trims trailing', () => { + const body = `\n## Collection\nline one\n\nline two\n\n\n## My Notes\n\nnote\n`; + expect(extractCustomSections(body)).toEqual([{ heading: 'Collection', content: 'line one\n\nline two' }]); + }); + test('no custom sections → empty array', () => { + const body = `\n# Loki\n\n## Links\n- x\n\n## My Notes\n\nnote\n`; + expect(extractCustomSections(body)).toEqual([]); + }); + test('empty/missing body → empty array', () => { + expect(extractCustomSections('')).toEqual([]); + }); +}); diff --git a/tests/watchlist-render.test.ts b/tests/watchlist-render.test.ts index 5555ad3..9fb2e73 100644 --- a/tests/watchlist-render.test.ts +++ b/tests/watchlist-render.test.ts @@ -37,3 +37,28 @@ describe('renderNote golden', () => { expect(renderNote(r, '')).not.toContain('**Rating:**'); }); }); + +describe('renderNote custom sections', () => { + test('no custom sections → output unchanged (golden)', () => { + const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-series.md'), 'utf-8'); + expect(renderNote(LOKI, '', [])).toBe(expected); + }); + test('single custom section placed after Links, before My Notes', () => { + const out = renderNote(LOKI, '', [{ heading: 'Collection', content: 'Part of [[Movies]]' }]); + const linksIdx = out.indexOf('## Links'); + const collectionIdx = out.indexOf('## Collection'); + const myNotesIdx = out.indexOf('## My Notes'); + expect(linksIdx).toBeGreaterThan(-1); + expect(collectionIdx).toBeGreaterThan(linksIdx); + expect(myNotesIdx).toBeGreaterThan(collectionIdx); + expect(out).toContain('## Collection\nPart of [[Movies]]\n'); + }); + test('multiple custom sections keep relative order', () => { + const out = renderNote(LOKI, '', [ + { heading: 'Collection', content: 'Part of [[Movies]]' }, + { heading: 'Rewatch Log', content: '- 2024-01-01' }, + ]); + expect(out.indexOf('## Collection')).toBeLessThan(out.indexOf('## Rewatch Log')); + expect(out.indexOf('## Rewatch Log')).toBeLessThan(out.indexOf('## My Notes')); + }); +}); diff --git a/tests/watchlist-sync-engine.test.ts b/tests/watchlist-sync-engine.test.ts index f0cb251..5bbed62 100644 --- a/tests/watchlist-sync-engine.test.ts +++ b/tests/watchlist-sync-engine.test.ts @@ -22,6 +22,13 @@ keep me const AIRING_NOTE = ENDED_NOTE.replace('status: Ended', 'status: Returning Series').replace('last_air_date: 2023-11-09', 'last_air_date: 2023-10-01'); +const NOTE_WITH_COLLECTION = ENDED_NOTE.replace('## My Notes', '## Collection\nPart of [[Movies]]\n\n## My Notes'); + +const NOTE_WITH_TWO_CUSTOM_SECTIONS = ENDED_NOTE.replace( + '## My Notes', + '## Collection\nPart of [[Movies]]\n\n## Rewatch Log\n- 2024-01-01\n- 2025-02-02\n\n## My Notes', +); + function makeDeps(notes: { path: string; content: string }[], detail: any = tvDetail) { const contents = new Map(notes.map(n => [n.path, n.content])); const writes: { path: string; content: string }[] = []; @@ -163,6 +170,38 @@ describe('syncFolder', () => { expect(report.errors[0].path).toBe('Bad.md'); expect(report.synced).toBe(1); }); + test('custom section round-trips through rewrite, positioned after Links and before My Notes', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: NOTE_WITH_COLLECTION }]); + await syncFolder(deps, { full: true }); + const out = writes[0].content; + expect(out).toContain('## Collection\nPart of [[Movies]]\n'); + const linksIdx = out.indexOf('## Links'); + const collectionIdx = out.indexOf('## Collection'); + const myNotesIdx = out.indexOf('## My Notes'); + expect(collectionIdx).toBeGreaterThan(linksIdx); + expect(myNotesIdx).toBeGreaterThan(collectionIdx); + }); + test('two custom sections keep relative order through rewrite', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: NOTE_WITH_TWO_CUSTOM_SECTIONS }]); + await syncFolder(deps, { full: true }); + const out = writes[0].content; + expect(out.indexOf('## Collection')).toBeLessThan(out.indexOf('## Rewatch Log')); + expect(out.indexOf('## Rewatch Log')).toBeLessThan(out.indexOf('## My Notes')); + }); + test('idempotence: custom section stable across two sync passes (render→parse→extract→render byte-identical)', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: NOTE_WITH_COLLECTION }]); + await syncFolder(deps, { full: true }); + const rendered = writes[0].content; + const second = makeDeps([{ path: 'Loki.md', content: rendered }]); + const report = await syncFolder(second.deps, { full: true }); + expect(second.writes.length).toBe(0); + expect(report.written).toBe(0); + }); + test('golden: note with no custom sections unaffected by custom-section plumbing', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]); + await syncFolder(deps, { full: true }); + expect(writes[0].content).not.toContain('## Collection'); + }); test('stale-read guard: content edited mid-sync is re-read fresh, not clobbered by early snapshot', async () => { const { deps, writes, contents } = makeDeps([ { path: 'A.md', content: AIRING_NOTE },