feat(library): local canonical conversion for id-less notes
This commit is contained in:
parent
75db364a1d
commit
8fb16b3220
13 changed files with 630 additions and 7 deletions
|
|
@ -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)`;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue