feat(library): local canonical conversion for id-less notes

This commit is contained in:
afiqzudinhadi 2026-08-05 20:55:42 +08:00
parent 75db364a1d
commit 8fb16b3220
13 changed files with 630 additions and 7 deletions

View file

@ -32,7 +32,7 @@ const KEY_LABEL: Record<'rawg' | 'comicvine', string> = {
};
function emptyReport(): LibraryReport {
return { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, flipped: [], errors: [] };
return { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, convertedLocal: 0, flipped: [], errors: [] };
}
function emptyResolveReport(): LibraryResolveReport {
@ -176,6 +176,7 @@ export class LibraryController {
* success just because skippedNoData/errors were left out of the message. */
private buildSyncSummary(spec: MediaTypeSpec, mode: string, report: LibraryReport): string {
let msg = `Library ${mode}sync (${spec.typeName}): ${report.scanned} scanned, ${report.synced} ok, ${report.written} updated, ${report.flipped.length} flipped`;
if (report.convertedLocal) msg += `, ${report.convertedLocal} converted (local)`;
if (report.skippedStatic) msg += `, ${report.skippedStatic} static`;
if (report.skippedNoId) msg += `, ${report.skippedNoId} no-id`;
if (report.skippedNoData) msg += `, ${report.skippedNoData} no-data (see console)`;

View file

@ -19,6 +19,7 @@ export interface LibraryReport {
skippedNoId: number;
skippedStatic: number;
skippedNoData: number;
convertedLocal: number;
flipped: string[];
errors: { path: string; error: string }[];
}
@ -51,7 +52,7 @@ function isSkippableNote(filename: string, fm: Record<string, string>, itemType:
}
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 report: LibraryReport = { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, convertedLocal: 0, flipped: [], errors: [] };
const notes = await deps.listNotes();
for (const note of notes) {
report.scanned++;
@ -68,6 +69,18 @@ export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngine
} else {
report.skippedNoId++;
}
// Id-less notes never reach spec.sync() (no API id to enrich from), but must still
// stop being permanently invisible to type-filtered Bases queries -- convertLocal
// builds canonical `<type>_item` content purely from what the note already has, no
// network. One-time in practice: re-parsing the converted note yields byte-identical
// output next pass, so diff-on-write means it's never rewritten (or re-counted) again.
const localCtx: LibraryNoteCtx = { frontmatter, body, filename };
const converted = spec.convertLocal(localCtx);
if (converted !== content) {
report.convertedLocal++;
if (!opts.dryRun) await deps.writeNote(note.path, converted);
deps.log(`${opts.dryRun ? '[dry] ' : ''}converted (local) ${note.path}`);
}
continue;
}
if (!opts.full && !spec.isActive(frontmatter)) {

View file

@ -58,6 +58,40 @@ export function buildBook(doc: any, prev: Record<string, string>): BookRecord {
};
}
/**
* Pure mapper: prev frontmatter only (no API payload) -> canonical BookRecord, for id-less
* notes that can never be resolved (or haven't been resolved yet) but still need a canonical
* `book_item` shape so they aren't permanently invisible to type-filtered Bases queries.
* User-managed fields go through the same derive helpers as buildBook (including the legacy
* singular `author` skeleton fallback); everything API-derived (year, pages, genre, poster,
* Open Library url) is empty/null. `title` falls back to the filename (minus `.md`) when the
* note has no title field at all.
*/
export function buildBookLocal(prev: Record<string, string>, filename: string): BookRecord {
const title = stripQuotes(prev['title']) || filename.replace(/\.md$/, '');
const { rating, ratingStars } = deriveRating(prev);
const readStatus = deriveReadStatus(prev);
// skeleton conversion: legacy singular `author` field -- mirrors deriveAuthors's own fallback
const legacyAuthor = stripQuotes(prev['author']);
const authors = legacyAuthor ? [legacyAuthor] : [];
const olid = stripQuotes(prev['olid']);
return {
title,
readStatus,
rating,
ratingStars,
authors,
year: null,
pages: null,
genre: [],
olid,
isbn: stripQuotes(prev['isbn']),
poster: null,
url: olid ? `${OPENLIBRARY_BASE}/works/${olid}` : '',
};
}
/** Carries the legacy goodreads search link forward as a `## Links` entry across every re-render. */
function resolveGoodreadsLink(prev: Record<string, string>, body: string): string {
const linksMatch = /##\s*Links\s*\n([\s\S]*?)(?=\n##\s|$)/.exec(body ?? '');
@ -226,4 +260,10 @@ export const bookSpec: MediaTypeSpec = {
const content = renderBook(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body), goodreadsUrl);
return { content, flipped: false }; // static spec -- no automation, never flips
},
convertLocal(ctx: LibraryNoteCtx): string {
const record = buildBookLocal(ctx.frontmatter, ctx.filename);
const goodreadsUrl = resolveGoodreadsLink(ctx.frontmatter, ctx.body);
return renderBook(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body), goodreadsUrl);
},
};

View file

@ -92,6 +92,41 @@ export function buildComic(cv: any, prev: Record<string, string>): ComicRecord {
};
}
/**
* Pure mapper: prev frontmatter only (no API payload) -> canonical ComicRecord, for id-less
* notes that can never be resolved (or haven't been resolved yet) but still need a canonical
* `comic_item` shape so they aren't permanently invisible to type-filtered Bases queries.
* User-managed fields go through the same derive helpers as buildComic (including deriveStatus's
* 'Ongoing' default); everything API-derived (issues, publisher, people, start year, poster,
* Comic Vine url, description) is empty/null. `title` falls back to the filename (minus `.md`)
* when the note has no title field at all.
*/
export function buildComicLocal(prev: Record<string, string>, filename: string): ComicRecord {
const title = stripQuotes(prev['title']) || filename.replace(/\.md$/, '');
const { rating, ratingStars } = deriveRating(prev);
const readStatus = deriveReadStatus(prev);
const lastReadIssue = stripQuotes(prev['last_read_issue']);
const comicvineId = stripQuotes(prev['comicvine_id']);
return {
title,
readStatus,
rating,
ratingStars,
lastReadIssue,
latestIssue: parseNumOrNull(prev['latest_issue']),
issues: null,
status: deriveStatus(prev),
publisher: '',
people: [],
startYear: '',
comicvineId,
poster: null,
url: '',
description: '',
};
}
export function renderComic(r: ComicRecord, myNotes: string, customSections: CustomSection[] = []): string {
const fm = [
'---',
@ -296,4 +331,9 @@ export const comicSpec: MediaTypeSpec = {
const content = renderComic(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
return { content, flipped };
},
convertLocal(ctx: LibraryNoteCtx): string {
const record = buildComicLocal(ctx.frontmatter, ctx.filename);
return renderComic(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
},
};

View file

@ -114,6 +114,43 @@ export function buildGame(source: 'steam' | 'rawg', data: any, prev: Record<stri
};
}
/**
* Pure mapper: prev frontmatter only (no API payload) -> canonical GameRecord, for id-less
* notes that can never be resolved (or haven't been resolved yet) but still need a canonical
* `game_item` shape so they aren't permanently invisible to type-filtered Bases queries.
* User-managed fields go through the same derive helpers as buildGame; everything API-derived
* (developer, publisher, platforms, genre, release date, metacritic, poster, description) is
* empty/null. `url` carries forward whatever the note already had (e.g. a Steam store link from
* the vault skeleton) untouched -- no synthetic link is built without a resolved id. `title`
* falls back to the filename (minus `.md`) when the note has no title field at all.
*/
export function buildGameLocal(prev: Record<string, string>, filename: string): GameRecord {
const title = stripQuotes(prev['title']) || filename.replace(/\.md$/, '');
const { rating, ratingStars } = deriveRating(prev);
const playStatus = derivePlayStatus(prev);
const steamAppid = stripQuotes(prev['steam_appid']);
const rawgId = stripQuotes(prev['rawg_id']);
const url = stripQuotes(prev['url']);
return {
title,
playStatus,
rating,
ratingStars,
developer: [],
publisher: [],
platforms: [],
genre: [],
releaseDate: '',
metacritic: null,
steamAppid,
rawgId,
poster: null,
url,
description: '',
};
}
export function renderGame(r: GameRecord, myNotes: string, customSections: CustomSection[] = []): string {
const fm = [
'---',
@ -315,4 +352,9 @@ export const gameSpec: MediaTypeSpec = {
return null; // steam_appid set, success:false, no rawg fallback available
},
convertLocal(ctx: LibraryNoteCtx): string {
const record = buildGameLocal(ctx.frontmatter, ctx.filename);
return renderGame(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
},
};

View file

@ -145,6 +145,52 @@ export function buildManga(jikan: any, prev: Record<string, string>): MangaRecor
};
}
/**
* Pure mapper: prev frontmatter only (no API payload) -> canonical MangaRecord, for id-less
* notes that can never be resolved (or haven't been resolved yet) but still need a canonical
* `manga_item` shape so they aren't permanently invisible to type-filtered Bases queries.
* User-managed fields go through the same derive helpers as buildManga/buildMangaFromAniList;
* everything API-derived (chapters, volumes, status, authors, genre, score, dates, poster,
* synopsis) is empty/null -- there's no source to pull it from. `title` falls back to the
* filename (minus `.md`) when the note has no title field at all.
*/
export function buildMangaLocal(prev: Record<string, string>, filename: string): MangaRecord {
const title = stripQuotes(prev['title']) || filename.replace(/\.md$/, '');
const { rating, ratingStars } = deriveRating(prev);
const readStatus = deriveReadStatus(prev);
const lastReadChapter = stripQuotes(prev['last_read_chapter']);
const rss = stripNullSentinel(stripQuotes(prev['rss']));
const mangadexId = stripQuotes(prev['mangadex_id']);
const malId = stripQuotes(prev['mal_id']);
const anilistId = stripQuotes(prev['anilist_id']);
return {
title,
engName: '',
readStatus,
rating,
ratingStars,
lastReadChapter,
latestChapter: parseNumOrNull(prev['latest_chapter']),
lastChapterDate: stripNullSentinel(stripQuotes(prev['last_chapter_date'])),
chapters: null,
volumes: null,
status: '',
authors: [],
genre: [],
score: null,
publishedFrom: null,
publishedTo: null,
malId,
anilistId,
mangadexId,
rss,
poster: null,
url: malId ? `https://myanimelist.net/manga/${malId}` : '',
synopsis: '',
};
}
/** AniList `{year,month,day}` date object -> ISO `YYYY-MM-DD`, or null when any part is missing
* (AniList leaves in-progress end dates as all-null rather than omitting the object). */
function aniListDate(d: { year?: number | null; month?: number | null; day?: number | null } | null | undefined): string | null {
@ -592,4 +638,9 @@ export const mangaSpec: MediaTypeSpec = {
const content = renderManga(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
return { content, flipped };
},
convertLocal(ctx: LibraryNoteCtx): string {
const record = buildMangaLocal(ctx.frontmatter, ctx.filename);
return renderManga(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
},
};

View file

@ -28,6 +28,11 @@ export interface MediaTypeSpec {
isActive(fm: Record<string, string>): boolean;
resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<ResolveOutcome | null>; // null = no match/error
sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null>; // full new note content; null = skip
// Network-free canonical conversion for id-less notes: builds full canonical note content
// purely from prev frontmatter + body (no API calls), so stock-skeleton/unresolvable notes
// still get a `<type>_item` shape (and so become visible to Bases) instead of staying invisible
// forever. Pure + idempotent -- same prev in, same content out, every time.
convertLocal(ctx: LibraryNoteCtx): string;
}
export interface SpecDeps {