feat(library): generic per-type sync engine + resolve

This commit is contained in:
afiqzudinhadi 2026-08-03 16:39:00 +08:00
parent c14da19c47
commit 278f009a9b
5 changed files with 571 additions and 13 deletions

View 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;
}

View file

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

View 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;
}