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:
afiqzudinhadi 2026-07-30 14:59:24 +08:00
parent 2c663568ca
commit fb3f52e4b0
6 changed files with 130 additions and 4 deletions

View file

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

View file

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

View file

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