feat(library): generic per-type sync engine + resolve
This commit is contained in:
parent
c14da19c47
commit
278f009a9b
5 changed files with 571 additions and 13 deletions
119
packages/obsidian/src/library/LibraryEngine.ts
Normal file
119
packages/obsidian/src/library/LibraryEngine.ts
Normal file
|
|
@ -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<string>;
|
||||
writeNote(path: string, content: string): Promise<void>;
|
||||
sleep(ms: number): Promise<void>;
|
||||
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 `<type>_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<string, string>, 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<LibraryReport> {
|
||||
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<LibraryResolveReport> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
24
packages/obsidian/src/watchlist/patchFrontmatter.ts
Normal file
24
packages/obsidian/src/watchlist/patchFrontmatter.ts
Normal file
|
|
@ -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<string, string>, 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;
|
||||
}
|
||||
392
tests/library-engine.test.ts
Normal file
392
tests/library-engine.test.ts
Normal file
|
|
@ -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<string, string>, 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<string, string>): boolean;
|
||||
isActive?(fm: Record<string, string>): boolean;
|
||||
sync?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType<MediaTypeSpec['sync']>;
|
||||
resolve?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType<MediaTypeSpec['resolve']>;
|
||||
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<SpecDeps> = {}): { deps: LibraryEngineDeps; writes: { path: string; content: string }[]; slept: number[]; contents: Map<string, string> } {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
34
tests/watchlist-patch-frontmatter.test.ts
Normal file
34
tests/watchlist-patch-frontmatter.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue