fix(watchlist): preserve custom body sections through sync rewrite
renderNote wiped any ## heading not owned by the renderer (Synopsis, Cast, Links, My Notes) on every sync, destroying user content like ## Collection graph-link sections. extractCustomSections now pulls non-owned sections out of the existing body before rewrite, and renderNote re-emits them verbatim, in original order, between Links and My Notes.
This commit is contained in:
parent
2c663568ca
commit
fb3f52e4b0
6 changed files with 130 additions and 4 deletions
|
|
@ -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 { buildRecord } from 'packages/obsidian/src/watchlist/build';
|
||||||
import { renderNote } from 'packages/obsidian/src/watchlist/render';
|
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 detail: any = await withRateLimitRetry(() => deps.fetchDetail(ref.tmdbId, ref.isMovie), deps.sleep);
|
||||||
const record = buildRecord(detail, ref.isMovie, frontmatter);
|
const record = buildRecord(detail, ref.isMovie, frontmatter);
|
||||||
const rendered = renderNote(record, extractMyNotes(body));
|
const rendered = renderNote(record, extractMyNotes(body), extractCustomSections(body));
|
||||||
report.synced++;
|
report.synced++;
|
||||||
if (strip(frontmatter['watch_status']) === 'Watched' && record.watchStatus === 'Unwatched') {
|
if (strip(frontmatter['watch_status']) === 'Watched' && record.watchStatus === 'Unwatched') {
|
||||||
report.flipped.push(note.path);
|
report.flipped.push(note.path);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,37 @@ export function extractMyNotes(body: string): string {
|
||||||
return m ? m[1].trim() : '';
|
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 {
|
export function stripQuotes(s: string | undefined): string {
|
||||||
return (s ?? '').trim().replace(/^"|"$/g, '');
|
return (s ?? '').trim().replace(/^"|"$/g, '');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema';
|
import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema';
|
||||||
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
|
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
|
||||||
|
import type { CustomSection } from 'packages/obsidian/src/watchlist/parse';
|
||||||
|
|
||||||
const CATEGORY_TAG: Record<string, string> = { Movie: 'movie', Series: 'series', Anime: 'anime' };
|
const CATEGORY_TAG: Record<string, string> = { 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 tag = CATEGORY_TAG[r.category];
|
||||||
const stars = r.ratingStars;
|
const stars = r.ratingStars;
|
||||||
const fm = [
|
const fm = [
|
||||||
|
|
@ -67,6 +68,7 @@ export function renderNote(r: WatchlistRecord, myNotes: string): string {
|
||||||
if (r.homepage) links.push(`- [Homepage](${r.homepage})`);
|
if (r.homepage) links.push(`- [Homepage](${r.homepage})`);
|
||||||
if (r.notionUrl) links.push(`- [Original Notion entry](${r.notionUrl})`);
|
if (r.notionUrl) links.push(`- [Original Notion entry](${r.notionUrl})`);
|
||||||
if (links.length) b.push('## Links', ...links, '');
|
if (links.length) b.push('## Links', ...links, '');
|
||||||
|
for (const s of customSections) b.push(`## ${s.heading}`, s.content, '');
|
||||||
b.push('## My Notes', '', myNotes);
|
b.push('## My Notes', '', myNotes);
|
||||||
if (myNotes) b.push('');
|
if (myNotes) b.push('');
|
||||||
return fm.join('\n') + '\n' + b.join('\n');
|
return fm.join('\n') + '\n' + b.join('\n');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from 'bun:test';
|
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 = `---
|
const NOTE = `---
|
||||||
type: watchlist_item
|
type: watchlist_item
|
||||||
|
|
@ -55,3 +55,32 @@ describe('noteTmdbRef', () => {
|
||||||
expect(noteTmdbRef({ type: 'list' })).toBeNull();
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -37,3 +37,28 @@ describe('renderNote golden', () => {
|
||||||
expect(renderNote(r, '')).not.toContain('**Rating:**');
|
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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 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) {
|
function makeDeps(notes: { path: string; content: string }[], detail: any = tvDetail) {
|
||||||
const contents = new Map(notes.map(n => [n.path, n.content]));
|
const contents = new Map(notes.map(n => [n.path, n.content]));
|
||||||
const writes: { path: string; content: string }[] = [];
|
const writes: { path: string; content: string }[] = [];
|
||||||
|
|
@ -163,6 +170,38 @@ describe('syncFolder', () => {
|
||||||
expect(report.errors[0].path).toBe('Bad.md');
|
expect(report.errors[0].path).toBe('Bad.md');
|
||||||
expect(report.synced).toBe(1);
|
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 () => {
|
test('stale-read guard: content edited mid-sync is re-read fresh, not clobbered by early snapshot', async () => {
|
||||||
const { deps, writes, contents } = makeDeps([
|
const { deps, writes, contents } = makeDeps([
|
||||||
{ path: 'A.md', content: AIRING_NOTE },
|
{ path: 'A.md', content: AIRING_NOTE },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue