diff --git a/packages/obsidian/src/library/LibraryEngine.ts b/packages/obsidian/src/library/LibraryEngine.ts new file mode 100644 index 0000000..6fe135d --- /dev/null +++ b/packages/obsidian/src/library/LibraryEngine.ts @@ -0,0 +1,119 @@ +import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import { parseNote, stripQuotes } from 'packages/obsidian/src/watchlist/parse'; +import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter'; +import { withRateLimitRetry } from 'packages/obsidian/src/watchlist/SyncEngine'; + +export interface LibraryEngineDeps { + listNotes(): Promise<{ path: string }[]>; + readNote(path: string): Promise; + writeNote(path: string, content: string): Promise; + sleep(ms: number): Promise; + log(msg: string): void; + specDeps: SpecDeps; +} + +export interface LibraryReport { + scanned: number; + synced: number; + written: number; + skippedNoId: number; + skippedStatic: number; + skippedNoData: number; + flipped: string[]; + errors: { path: string; error: string }[]; +} + +export interface LibraryResolveReport { + resolved: string[]; + ambiguous: string[]; + errors: { path: string; error: string }[]; +} + +// Notes with `type: comicManga|book|game` are stock-skeleton entries (pre-conversion, all +// vault types collapse to these 3 legacy MediaType values) and must NOT be skipped -- the +// first sync converts them to their canonical `_item` shape. +const STOCK_SKELETON_TYPES = new Set(['comicManga', 'book', 'game']); + +function filenameOf(path: string): string { + return path.split('/').pop() ?? path; +} + +/** `_`-prefixed notes (dashboards/indexes) and non-entry notes (e.g. `type: folder_index`) are silently skipped. */ +function isSkippableNote(filename: string, fm: Record, itemType: string): boolean { + if (filename.startsWith('_')) return true; + const type = stripQuotes(fm['type']); + if (!type) return false; + if (type === itemType) return false; + if (STOCK_SKELETON_TYPES.has(type)) return false; + return true; +} + +export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngineDeps, opts: { full?: boolean; dryRun?: boolean } = {}): Promise { + const report: LibraryReport = { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, flipped: [], errors: [] }; + const notes = await deps.listNotes(); + for (const note of notes) { + report.scanned++; + try { + // Read immediately before parse/diff so mid-sync edits aren't clobbered by a stale snapshot. + const content = await deps.readNote(note.path); + const { frontmatter, body } = parseNote(content); + const filename = filenameOf(note.path); + if (isSkippableNote(filename, frontmatter, spec.itemType)) continue; + if (!spec.hasId(frontmatter)) { + report.skippedNoId++; + continue; + } + if (!opts.full && !spec.isActive(frontmatter)) { + report.skippedStatic++; + continue; + } + const ctx: LibraryNoteCtx = { frontmatter, body, filename }; + const result = await withRateLimitRetry(() => spec.sync(ctx, deps.specDeps), deps.sleep); + if (!result) { + report.skippedNoData++; + } else { + report.synced++; + if (result.flipped) report.flipped.push(note.path); + if (result.content !== content) { + report.written++; + if (!opts.dryRun) await deps.writeNote(note.path, result.content); + deps.log(`${opts.dryRun ? '[dry] ' : ''}updated ${note.path}`); + } + } + await deps.sleep(spec.throttleMs); + } catch (e) { + report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) }); + deps.log(`ERROR ${note.path}: ${String(e)}`); + } + } + return report; +} + +export async function libraryFolderResolve(spec: MediaTypeSpec, deps: LibraryEngineDeps, opts: { dryRun?: boolean } = {}): Promise { + const report: LibraryResolveReport = { resolved: [], ambiguous: [], errors: [] }; + const notes = await deps.listNotes(); + for (const note of notes) { + try { + const content = await deps.readNote(note.path); + const { frontmatter, body } = parseNote(content); + const filename = filenameOf(note.path); + if (isSkippableNote(filename, frontmatter, spec.itemType)) continue; + if (spec.hasId(frontmatter)) continue; // already resolved + const ctx: LibraryNoteCtx = { frontmatter, body, filename }; + const patch = await withRateLimitRetry(() => spec.resolve(ctx, deps.specDeps), deps.sleep); + if (!patch) { + report.ambiguous.push(note.path); + deps.log(`ambiguous/no match: ${note.path}`); + } else { + report.resolved.push(note.path); + deps.log(`${opts.dryRun ? '[dry] ' : ''}resolved ${note.path}`); + if (!opts.dryRun) await deps.writeNote(note.path, patchFrontmatter(content, patch, { defaultType: spec.itemType })); + } + await deps.sleep(spec.throttleMs); + } catch (e) { + report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) }); + deps.log(`ERROR ${note.path}: ${String(e)}`); + } + } + return report; +} diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts index 5928377..fd38794 100644 --- a/packages/obsidian/src/watchlist/WatchlistController.ts +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -4,6 +4,7 @@ import { syncFolder, withRateLimitRetry, TmdbRateLimitError, type SyncDeps, type import { fetchDetail, searchTitle, type HttpJsonFn } from 'packages/obsidian/src/watchlist/tmdb'; import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; import { parseNote, noteTmdbRef, stripQuotes } from 'packages/obsidian/src/watchlist/parse'; +import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter'; import { resolveNote } from 'packages/obsidian/src/watchlist/resolve'; export interface ResolveReport { @@ -13,18 +14,6 @@ export interface ResolveReport { errors: { path: string; error: string }[]; } -function patchFrontmatter(content: string, tmdbId: string, mediaType: string): string { - const insert = `tmdb_id: ${tmdbId}\nmedia_type: ${mediaType}`; - const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content); - if (fmMatch) { - const inner = fmMatch[1]; - const typeLine = /^type:.*$/m.exec(inner); - const newInner = typeLine ? inner.slice(0, typeLine.index + typeLine[0].length) + '\n' + insert + inner.slice(typeLine.index + typeLine[0].length) : insert + '\n' + inner; - return content.slice(0, fmMatch.index) + '---\n' + newInner + '\n---' + content.slice(fmMatch.index + fmMatch[0].length); - } - return `---\ntype: watchlist_item\n${insert}\n---\n\n` + content; -} - export function shouldNotifySync(quiet: boolean, written: number, errorCount: number): boolean { return !quiet || written > 0 || errorCount > 0; } @@ -146,7 +135,7 @@ export class WatchlistController { report.resolved++; const mediaType = result.isMovie ? 'Movie' : 'TV Series'; deps.log(`${dryRun ? '[dry] ' : ''}resolved ${note.path} -> tmdb_id ${result.tmdbId} (${result.matchedTitle})`); - if (!dryRun) await deps.writeNote(note.path, patchFrontmatter(content, result.tmdbId, mediaType)); + if (!dryRun) await deps.writeNote(note.path, patchFrontmatter(content, { tmdb_id: result.tmdbId, media_type: mediaType }, { defaultType: 'watchlist_item' })); } await deps.sleep(250); } catch (e) { diff --git a/packages/obsidian/src/watchlist/patchFrontmatter.ts b/packages/obsidian/src/watchlist/patchFrontmatter.ts new file mode 100644 index 0000000..7638c3b --- /dev/null +++ b/packages/obsidian/src/watchlist/patchFrontmatter.ts @@ -0,0 +1,24 @@ +/** + * Insert frontmatter key: value patches into note content, right after the `type:` line + * when frontmatter exists (or prepended before the rest of the frontmatter when there's + * no `type:` line). When the note has no frontmatter block at all, a new one is created, + * optionally seeded with `defaultType`. + * + * Shared by watchlist's resolveMissingIds and the library resolve engine. + */ +export function patchFrontmatter(content: string, patches: Record, opts: { defaultType?: string } = {}): string { + const insert = Object.entries(patches) + .map(([k, v]) => `${k}: ${v}`) + .join('\n'); + const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content); + if (fmMatch) { + const inner = fmMatch[1]; + const typeLine = /^type:.*$/m.exec(inner); + const newInner = typeLine + ? inner.slice(0, typeLine.index + typeLine[0].length) + '\n' + insert + inner.slice(typeLine.index + typeLine[0].length) + : insert + '\n' + inner; + return content.slice(0, fmMatch.index) + '---\n' + newInner + '\n---' + content.slice(fmMatch.index + fmMatch[0].length); + } + const typeLine = opts.defaultType ? `type: ${opts.defaultType}\n` : ''; + return `---\n${typeLine}${insert}\n---\n\n` + content; +} diff --git a/tests/library-engine.test.ts b/tests/library-engine.test.ts new file mode 100644 index 0000000..a1fcd24 --- /dev/null +++ b/tests/library-engine.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, test } from 'bun:test'; +import { libraryFolderSync, libraryFolderResolve, type LibraryEngineDeps } from 'packages/obsidian/src/library/LibraryEngine'; +import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { extractMyNotes } from 'packages/obsidian/src/watchlist/parse'; +import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; + +const FAKE_ITEM_TYPE = 'fake_item'; + +function renderFake(fm: Record, myNotes: string): string { + return `---\ntype: ${FAKE_ITEM_TYPE}\nfake_id: ${fm['fake_id'] ?? ''}\nstatus: ${fm['status'] ?? ''}\n---\n\n# Fake\n\n## My Notes\n\n${myNotes}\n`; +} + +interface FakeSpecOptions { + hasId?(fm: Record): boolean; + isActive?(fm: Record): boolean; + sync?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType; + resolve?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType; + throttleMs?: number; +} + +function makeFakeSpec(opts: FakeSpecOptions = {}) { + const syncCalls: LibraryNoteCtx[] = []; + const resolveCalls: LibraryNoteCtx[] = []; + const spec: MediaTypeSpec = { + typeName: 'manga', + itemType: FAKE_ITEM_TYPE, + folderSettingKey: 'fakeFolder', + enabledSettingKey: 'fakeEnabled', + throttleMs: opts.throttleMs ?? 111, + hasId: opts.hasId ?? (fm => !!fm['fake_id']), + isActive: opts.isActive ?? (fm => fm['status'] !== 'Done'), + resolve: async (ctx, deps) => { + resolveCalls.push(ctx); + return opts.resolve ? await opts.resolve(ctx, deps) : null; + }, + sync: async (ctx, deps) => { + syncCalls.push(ctx); + if (opts.sync) return await opts.sync(ctx, deps); + return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false }; + }, + }; + return { spec, syncCalls, resolveCalls }; +} + +const ACTIVE_NOTE = `--- +type: fake_item +fake_id: 42 +status: Active +--- + +# Raw Input + +## My Notes + +keep me +`; + +const DONE_NOTE = ACTIVE_NOTE.replace('status: Active', 'status: Done'); +const NO_ID_NOTE = ACTIVE_NOTE.replace('fake_id: 42', 'fake_id: '); + +function makeDeps(notes: { path: string; content: string }[], specDeps: Partial = {}): { deps: LibraryEngineDeps; writes: { path: string; content: string }[]; slept: number[]; contents: Map } { + const contents = new Map(notes.map(n => [n.path, n.content])); + const writes: { path: string; content: string }[] = []; + const slept: number[] = []; + const deps: LibraryEngineDeps = { + listNotes: async () => notes.map(n => ({ path: n.path })), + readNote: async path => contents.get(path)!, + writeNote: async (path, content) => { + writes.push({ path, content }); + }, + sleep: async ms => { + slept.push(ms); + }, + log: () => {}, + specDeps: { + http: async () => ({}), + httpText: async () => '', + getKey: () => '', + log: () => {}, + notify: () => {}, + ...specDeps, + }, + }; + return { deps, writes, slept, contents }; +} + +describe('libraryFolderSync: tiering', () => { + test('hasId false -> skippedNoId, sync not called', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const { deps } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.skippedNoId).toBe(1); + expect(syncCalls.length).toBe(0); + }); + + test('inactive + no full -> skippedStatic, sync not called', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const { deps } = makeDeps([{ path: 'A.md', content: DONE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.skippedStatic).toBe(1); + expect(syncCalls.length).toBe(0); + }); + + test('full:true overrides inactive tiering -> sync called', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const { deps } = makeDeps([{ path: 'A.md', content: DONE_NOTE }]); + const report = await libraryFolderSync(spec, deps, { full: true }); + expect(report.skippedStatic).toBe(0); + expect(syncCalls.length).toBe(1); + }); +}); + +describe('libraryFolderSync: diff-on-write + dryRun', () => { + test('diff-on-write: second pass on rendered output writes nothing', async () => { + const { spec } = makeFakeSpec(); + const { deps, writes } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(writes.length).toBe(1); + const rendered = writes[0].content; + + const second = makeDeps([{ path: 'A.md', content: rendered }]); + const report2 = await libraryFolderSync(spec, second.deps, {}); + expect(second.writes.length).toBe(0); + expect(report2.written).toBe(0); + }); + + test('dryRun: no writeNote call, but written count reflects what would change', async () => { + const { spec } = makeFakeSpec(); + const { deps, writes } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, { dryRun: true }); + expect(writes.length).toBe(0); + expect(report.written).toBe(1); + }); + + test('My Notes preserved through rewrite', async () => { + const { spec } = makeFakeSpec(); + const { deps, writes } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(writes[0].content).toContain('keep me'); + }); +}); + +describe('libraryFolderSync: flip bookkeeping', () => { + test('spec.sync flipped:true -> report.flipped includes path', async () => { + const { spec } = makeFakeSpec({ + sync: async ctx => ({ content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: true }), + }); + const { deps } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.flipped).toEqual(['A.md']); + }); + + test('spec.sync flipped:false -> report.flipped stays empty', async () => { + const { spec } = makeFakeSpec(); + const { deps } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.flipped).toEqual([]); + }); +}); + +describe('libraryFolderSync: skippedNoData (null sync result)', () => { + test('spec.sync returns null -> skippedNoData++, no write, no throw', async () => { + const { spec } = makeFakeSpec({ sync: async () => null }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.skippedNoData).toBe(1); + expect(report.synced).toBe(0); + expect(writes.length).toBe(0); + expect(report.errors.length).toBe(0); + }); +}); + +describe('libraryFolderSync: 429 retry', () => { + test('spec.sync throws TmdbRateLimitError once -> sleep(retryAfterMs) then retry succeeds', async () => { + let calls = 0; + const { spec } = makeFakeSpec({ + sync: async ctx => { + calls++; + if (calls === 1) { + const e = new TmdbRateLimitError('429'); + e.retryAfterMs = 1500; + throw e; + } + return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false }; + }, + }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(calls).toBe(2); + expect(slept).toContain(1500); + expect(report.errors.length).toBe(0); + }); +}); + +describe('libraryFolderSync: error isolation', () => { + test('one note errors, others still processed and counted', async () => { + let n = 0; + const { spec } = makeFakeSpec({ + sync: async ctx => { + n++; + if (n === 1) throw new Error('boom'); + return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false }; + }, + }); + const { deps } = makeDeps([ + { path: 'Bad.md', content: ACTIVE_NOTE }, + { path: 'Good.md', content: ACTIVE_NOTE }, + ]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.errors.length).toBe(1); + expect(report.errors[0].path).toBe('Bad.md'); + expect(report.synced).toBe(1); + expect(report.scanned).toBe(2); + }); +}); + +describe('libraryFolderSync: fresh-read mid-sync', () => { + test('content edited mid-sync is re-read fresh, not clobbered by early snapshot', async () => { + const { spec } = makeFakeSpec(); + const { deps, writes, contents } = makeDeps([ + { path: 'A.md', content: ACTIVE_NOTE }, + { path: 'B.md', content: ACTIVE_NOTE }, + ]); + let calls = 0; + const originalRead = deps.readNote; + deps.readNote = async path => { + calls++; + if (calls === 1) { + contents.set('B.md', ACTIVE_NOTE.replace('keep me', 'edited during sync')); + } + return originalRead(path); + }; + await libraryFolderSync(spec, deps, {}); + const bWrite = writes.find(w => w.path === 'B.md'); + expect(bWrite?.content).toContain('edited during sync'); + expect(bWrite?.content).not.toContain('keep me'); + }); +}); + +describe('libraryFolderSync: skip rules (underscore / non-entry / stock skeleton)', () => { + test('`_`-prefixed note skipped entirely, no counters bumped besides scanned', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const { deps } = makeDeps([{ path: '_Dashboard.md', content: '# dash' }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.scanned).toBe(1); + expect(report.skippedNoId).toBe(0); + expect(report.skippedStatic).toBe(0); + expect(report.skippedNoData).toBe(0); + expect(report.errors.length).toBe(0); + expect(syncCalls.length).toBe(0); + }); + + test('type: folder_index (non-entry, non-stock) skipped entirely', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const note = `---\ntype: folder_index\n---\n\n# Index\n`; + const { deps } = makeDeps([{ path: 'Index.md', content: note }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.scanned).toBe(1); + expect(report.skippedNoId).toBe(0); + expect(report.skippedStatic).toBe(0); + expect(syncCalls.length).toBe(0); + }); + + test('stock skeleton type (e.g. `type: book`) NOT skipped -> reaches hasId/sync gate', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const note = `---\ntype: book\nfake_id: 42\nstatus: Active\n---\n\nbody\n`; + const { deps } = makeDeps([{ path: 'Skeleton.md', content: note }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(syncCalls.length).toBe(1); + expect(report.synced).toBe(1); + }); + + test('stock skeleton without id yet still reaches hasId gate (skippedNoId, not silent skip)', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const note = `---\ntype: game\nstatus: Active\n---\n\nbody\n`; + const { deps } = makeDeps([{ path: 'Skeleton.md', content: note }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(syncCalls.length).toBe(0); + expect(report.skippedNoId).toBe(1); + }); +}); + +describe('libraryFolderSync: throttle-after-success', () => { + test('sleeps spec.throttleMs after a processed note', async () => { + const { spec } = makeFakeSpec({ throttleMs: 777 }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(slept).toContain(777); + }); + + test('does not sleep for skippedStatic notes', async () => { + const { spec } = makeFakeSpec({ throttleMs: 777 }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: DONE_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(slept).not.toContain(777); + }); + + test('does not sleep for `_`-prefixed skipped notes', async () => { + const { spec } = makeFakeSpec({ throttleMs: 777 }); + const { deps, slept } = makeDeps([{ path: '_Dashboard.md', content: '# dash' }]); + await libraryFolderSync(spec, deps, {}); + expect(slept).not.toContain(777); + }); +}); + +describe('libraryFolderResolve', () => { + test('missing id -> spec.resolve patches written via patchFrontmatter', async () => { + const { spec } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(report.resolved).toEqual(['A.md']); + expect(report.ambiguous).toEqual([]); + expect(writes.length).toBe(1); + expect(writes[0].content).toContain('fake_id: 99'); + expect(writes[0].content).toContain('type: fake_item'); + }); + + test('ambiguous: spec.resolve returns null -> report.ambiguous, no write', async () => { + const { spec } = makeFakeSpec({ resolve: async () => null }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(report.ambiguous).toEqual(['A.md']); + expect(report.resolved).toEqual([]); + expect(writes.length).toBe(0); + }); + + test('hasId already true -> resolve() not called, note excluded from both lists', async () => { + const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { deps } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(resolveCalls.length).toBe(0); + expect(report.resolved).toEqual([]); + expect(report.ambiguous).toEqual([]); + }); + + test('dryRun: resolved counted but no writeNote call', async () => { + const { spec } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, { dryRun: true }); + expect(report.resolved).toEqual(['A.md']); + expect(writes.length).toBe(0); + }); + + test('error isolation: one note throws, other still resolved', async () => { + let n = 0; + const { spec } = makeFakeSpec({ + resolve: async () => { + n++; + if (n === 1) throw new Error('boom'); + return { fake_id: '99' }; + }, + }); + const { deps } = makeDeps([ + { path: 'Bad.md', content: NO_ID_NOTE }, + { path: 'Good.md', content: NO_ID_NOTE }, + ]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(report.errors.length).toBe(1); + expect(report.errors[0].path).toBe('Bad.md'); + expect(report.resolved).toEqual(['Good.md']); + }); + + test('`_`-prefixed / non-entry notes excluded from resolve pass', async () => { + const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const note = `---\ntype: folder_index\n---\n\n# Index\n`; + const { deps } = makeDeps([{ path: 'Index.md', content: note }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(resolveCalls.length).toBe(0); + expect(report.resolved).toEqual([]); + expect(report.ambiguous).toEqual([]); + }); + + test('429 retry: spec.resolve throws TmdbRateLimitError once then succeeds', async () => { + let calls = 0; + const { spec } = makeFakeSpec({ + resolve: async () => { + calls++; + if (calls === 1) { + const e = new TmdbRateLimitError('429'); + e.retryAfterMs = 1200; + throw e; + } + return { fake_id: '99' }; + }, + }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(calls).toBe(2); + expect(slept).toContain(1200); + expect(report.resolved).toEqual(['A.md']); + }); +}); diff --git a/tests/watchlist-patch-frontmatter.test.ts b/tests/watchlist-patch-frontmatter.test.ts new file mode 100644 index 0000000..3bb1904 --- /dev/null +++ b/tests/watchlist-patch-frontmatter.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter'; + +describe('patchFrontmatter', () => { + test('inserts patches right after the type: line when frontmatter exists', () => { + const content = '---\ntype: watchlist_item\ntitle: Loki\n---\n\nbody'; + const out = patchFrontmatter(content, { tmdb_id: '84958', media_type: 'TV Series' }); + expect(out).toBe('---\ntype: watchlist_item\ntmdb_id: 84958\nmedia_type: TV Series\ntitle: Loki\n---\n\nbody'); + }); + + test('no type: line -> patches prepended before existing frontmatter', () => { + const content = '---\ntitle: Loki\n---\n\nbody'; + const out = patchFrontmatter(content, { tmdb_id: '1' }); + expect(out).toBe('---\ntmdb_id: 1\ntitle: Loki\n---\n\nbody'); + }); + + test('no frontmatter at all -> creates block with defaultType', () => { + const content = '# Loki\n\nbody'; + const out = patchFrontmatter(content, { tmdb_id: '1' }, { defaultType: 'watchlist_item' }); + expect(out).toBe('---\ntype: watchlist_item\ntmdb_id: 1\n---\n\n# Loki\n\nbody'); + }); + + test('no frontmatter, no defaultType -> no type line inserted', () => { + const content = 'body only'; + const out = patchFrontmatter(content, { mal_id: '5' }); + expect(out).toBe('---\nmal_id: 5\n---\n\nbody only'); + }); + + test('multiple patch keys preserve insertion order', () => { + const content = '---\ntype: manga_item\n---\n'; + const out = patchFrontmatter(content, { mal_id: '5', mangadex_id: 'abc' }); + expect(out).toContain('mal_id: 5\nmangadex_id: abc'); + }); +});