From 8fb16b3220107f3c45d7c82292a06f5b92841ccf Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 20:55:42 +0800 Subject: [PATCH] feat(library): local canonical conversion for id-less notes --- .../obsidian/src/library/LibraryController.ts | 3 +- .../obsidian/src/library/LibraryEngine.ts | 15 ++- packages/obsidian/src/library/book.ts | 40 +++++++ packages/obsidian/src/library/comic.ts | 40 +++++++ packages/obsidian/src/library/game.ts | 42 ++++++++ packages/obsidian/src/library/manga.ts | 51 +++++++++ packages/obsidian/src/library/types.ts | 5 + tests/library-book.test.ts | 89 ++++++++++++++- tests/library-comic.test.ts | 88 ++++++++++++++- tests/library-controller.test.ts | 2 + tests/library-engine.test.ts | 70 +++++++++++- tests/library-game.test.ts | 90 +++++++++++++++- tests/library-manga.test.ts | 102 +++++++++++++++++- 13 files changed, 630 insertions(+), 7 deletions(-) diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 760391c..69e785c 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -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)`; diff --git a/packages/obsidian/src/library/LibraryEngine.ts b/packages/obsidian/src/library/LibraryEngine.ts index dc98f73..adee18b 100644 --- a/packages/obsidian/src/library/LibraryEngine.ts +++ b/packages/obsidian/src/library/LibraryEngine.ts @@ -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, itemType: } 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 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 `_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)) { diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 7b2f012..3c88eb3 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -58,6 +58,40 @@ export function buildBook(doc: any, prev: Record): 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, 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, 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); + }, }; diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index 95bc258..ff02ee0 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -92,6 +92,41 @@ export function buildComic(cv: any, prev: Record): 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, 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)); + }, }; diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts index a263083..489537f 100644 --- a/packages/obsidian/src/library/game.ts +++ b/packages/obsidian/src/library/game.ts @@ -114,6 +114,43 @@ export function buildGame(source: 'steam' | 'rawg', data: any, prev: Record 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, 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)); + }, }; diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index 0d203c0..103e7f4 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -145,6 +145,52 @@ export function buildManga(jikan: any, prev: Record): 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, 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)); + }, }; diff --git a/packages/obsidian/src/library/types.ts b/packages/obsidian/src/library/types.ts index c5087fd..793058e 100644 --- a/packages/obsidian/src/library/types.ts +++ b/packages/obsidian/src/library/types.ts @@ -28,6 +28,11 @@ export interface MediaTypeSpec { isActive(fm: Record): boolean; resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise; // 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 `_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 { diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index 60bae56..4d290bc 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { buildBook, renderBook, bookSpec, type BookRecord } from 'packages/obsidian/src/library/book'; +import { buildBook, buildBookLocal, renderBook, bookSpec, type BookRecord } from 'packages/obsidian/src/library/book'; import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; import olFixture from 'tests/fixtures/openlibrary-search.json'; @@ -421,3 +421,90 @@ describe('bookSpec.resolve — author hint (stock `author` / canonical `authors` expect(result).toEqual({ patches: { olid: 'OL1168083W' } }); }); }); + +describe('buildBookLocal: pure prev-only mapper (no API payload)', () => { + test('empty prev + filename fallback -> title from filename, everything else empty/null', () => { + const r = buildBookLocal({}, 'Nineteen Eighty-Four.md'); + expect(r.title).toBe('Nineteen Eighty-Four'); + expect(r.readStatus).toBe('Unread'); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + expect(r.authors).toEqual([]); + expect(r.year).toBeNull(); + expect(r.pages).toBeNull(); + expect(r.genre).toEqual([]); + expect(r.olid).toBe(''); + expect(r.isbn).toBe(''); + expect(r.poster).toBeNull(); + expect(r.url).toBe(''); + }); + + test('prev title wins over filename', () => { + expect(buildBookLocal({ title: '1984' }, 'Nineteen Eighty-Four.md').title).toBe('1984'); + }); + + test('skeleton legacy fields (read/personalRating/author) converted via existing derive helpers', () => { + const r = buildBookLocal({ read: 'true', personalRating: '4', author: 'George Orwell' }, 'X.md'); + expect(r.readStatus).toBe('Read'); + expect(r.rating).toBe('4'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + expect(r.authors).toEqual(['George Orwell']); + }); + + test('carries whatever olid/isbn prev already has', () => { + const r = buildBookLocal({ olid: 'OL1168083W', isbn: '9780451524935' }, 'X.md'); + expect(r.olid).toBe('OL1168083W'); + expect(r.isbn).toBe('9780451524935'); + expect(r.url).toBe('https://openlibrary.org/works/OL1168083W'); + }); +}); + +describe('bookSpec.convertLocal: no-network canonical conversion for id-less notes', () => { + test('stock-skeleton note -> canonical book_item shape, title falls back to filename', () => { + const fm = { type: 'book', read: 'true', personalRating: '3', author: 'George Orwell' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\nsome notes', filename: '1984.md' }; + const content = bookSpec.convertLocal(ctx); + expect(content).toContain('type: book_item'); + expect(content).toContain('title: 1984'); + expect(content).toContain('read_status: Read'); + expect(content).toContain('rating: 3'); + expect(content).toContain('## My Notes'); + expect(content).toContain('some notes'); + }); + + test('legacy plain `url` field carried forward as a Goodreads Links entry', () => { + const fm = { type: 'book', url: 'https://www.goodreads.com/book/show/5470.1984' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: '1984.md' }; + const content = bookSpec.convertLocal(ctx); + expect(content).toContain('[Goodreads](https://www.goodreads.com/book/show/5470.1984)'); + }); + + test('preserves custom sections through conversion', () => { + const ctx: LibraryNoteCtx = { + frontmatter: {}, + body: '## Quotes\n\nsome quote\n\n## My Notes\n\nkeep me', + filename: 'X.md', + }; + const content = bookSpec.convertLocal(ctx); + expect(content).toContain('## Quotes'); + expect(content).toContain('some quote'); + expect(content).toContain('keep me'); + }); + + test('round-trip idempotence: re-running convertLocal on its own output yields byte-identical content', () => { + const fm = { type: 'book', read: 'false', personalRating: '', url: 'https://www.goodreads.com/book/show/5470.1984' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: '1984.md' }; + const once = bookSpec.convertLocal(ctx); + const { frontmatter: fm2, body: body2 } = (() => { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(once)!; + const f: Record = {}; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) f[mm[1]] = mm[2].trim(); + } + return { frontmatter: f, body: m[2] }; + })(); + const twice = bookSpec.convertLocal({ frontmatter: fm2, body: body2, filename: '1984.md' }); + expect(twice).toBe(once); + }); +}); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index 7668936..4c03a3e 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { buildComic, renderComic, htmlToPlainText, comicSpec, type ComicRecord } from 'packages/obsidian/src/library/comic'; +import { buildComic, buildComicLocal, renderComic, htmlToPlainText, comicSpec, type ComicRecord } from 'packages/obsidian/src/library/comic'; import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; import comicvineFixture from 'tests/fixtures/comicvine-volume.json'; @@ -545,3 +545,89 @@ describe('comicSpec.sync — seed pass (I3): no stored latest_issue never flips expect(deps.notifyCalls).toEqual(['«Absolute Batman» issue 11 out']); }); }); + +describe('buildComicLocal: pure prev-only mapper (no API payload)', () => { + test('empty prev + filename fallback -> title from filename, everything else empty/null', () => { + const r = buildComicLocal({}, 'Ghostblade.md'); + expect(r.title).toBe('Ghostblade'); + expect(r.readStatus).toBe('Unread'); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + expect(r.lastReadIssue).toBe(''); + expect(r.latestIssue).toBeNull(); + expect(r.issues).toBeNull(); + expect(r.status).toBe('Ongoing'); + expect(r.publisher).toBe(''); + expect(r.people).toEqual([]); + expect(r.startYear).toBe(''); + expect(r.comicvineId).toBe(''); + expect(r.poster).toBeNull(); + expect(r.url).toBe(''); + expect(r.description).toBe(''); + }); + + test('prev title wins over filename', () => { + expect(buildComicLocal({ title: 'Ghostblade' }, 'X.md').title).toBe('Ghostblade'); + }); + + test('skeleton legacy fields (read/personalRating) converted via existing derive helpers', () => { + const r = buildComicLocal({ read: 'true', personalRating: '5' }, 'X.md'); + expect(r.readStatus).toBe('Read'); + expect(r.rating).toBe('5'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️⭐️'); + }); + + test('existing status carried forward, not overwritten to the Ongoing default', () => { + expect(buildComicLocal({ status: 'Finished' }, 'X.md').status).toBe('Finished'); + }); + + test('carries whatever comicvine_id/last_read_issue/latest_issue prev already has', () => { + const r = buildComicLocal({ comicvine_id: '195824', last_read_issue: '5', latest_issue: '10' }, 'X.md'); + expect(r.comicvineId).toBe('195824'); + expect(r.lastReadIssue).toBe('5'); + expect(r.latestIssue).toBe(10); + }); +}); + +describe('comicSpec.convertLocal: no-network canonical conversion for id-less notes', () => { + test('stock-skeleton note -> canonical comic_item shape, title falls back to filename', () => { + const fm = { type: 'comicManga', read: 'true', personalRating: '3' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\nsome notes', filename: 'Ghostblade.md' }; + const content = comicSpec.convertLocal(ctx); + expect(content).toContain('type: comic_item'); + expect(content).toContain('title: Ghostblade'); + expect(content).toContain('read_status: Read'); + expect(content).toContain('rating: 3'); + expect(content).toContain('## My Notes'); + expect(content).toContain('some notes'); + }); + + test('preserves custom sections through conversion', () => { + const ctx: LibraryNoteCtx = { + frontmatter: {}, + body: '## Story Arcs\n\narc 1\n\n## My Notes\n\nkeep me', + filename: 'X.md', + }; + const content = comicSpec.convertLocal(ctx); + expect(content).toContain('## Story Arcs'); + expect(content).toContain('arc 1'); + expect(content).toContain('keep me'); + }); + + test('round-trip idempotence: re-running convertLocal on its own output yields byte-identical content', () => { + const fm = { type: 'comicManga', read: 'false', personalRating: '' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: 'Ghostblade.md' }; + const once = comicSpec.convertLocal(ctx); + const { frontmatter: fm2, body: body2 } = (() => { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(once)!; + const f: Record = {}; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) f[mm[1]] = mm[2].trim(); + } + return { frontmatter: f, body: m[2] }; + })(); + const twice = comicSpec.convertLocal({ frontmatter: fm2, body: body2, filename: 'Ghostblade.md' }); + expect(twice).toBe(once); + }); +}); diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index dba23fe..9827b25 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -449,6 +449,7 @@ describe('sync summary transparency', () => { isActive: () => true, resolve: async () => null, sync: async () => null, + convertLocal: () => '', }; const report = await c.syncType(noDataSpec, false); @@ -478,6 +479,7 @@ describe('resolveType: interactive candidate picker (needsChoice)', () => { isActive: () => true, resolve: async () => ({ candidates: CANDIDATES }), sync: async () => null, + convertLocal: () => '', }; function makeCandidateDeps(writes: { path: string; content: string }[]): LibraryEngineDeps { diff --git a/tests/library-engine.test.ts b/tests/library-engine.test.ts index 8b8150a..83feb02 100644 --- a/tests/library-engine.test.ts +++ b/tests/library-engine.test.ts @@ -15,12 +15,14 @@ interface FakeSpecOptions { isActive?(fm: Record): boolean; sync?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType; resolve?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType; + convertLocal?(ctx: LibraryNoteCtx): string; throttleMs?: number; } function makeFakeSpec(opts: FakeSpecOptions = {}) { const syncCalls: LibraryNoteCtx[] = []; const resolveCalls: LibraryNoteCtx[] = []; + const convertLocalCalls: LibraryNoteCtx[] = []; const spec: MediaTypeSpec = { typeName: 'manga', itemType: FAKE_ITEM_TYPE, @@ -38,8 +40,12 @@ function makeFakeSpec(opts: FakeSpecOptions = {}) { if (opts.sync) return await opts.sync(ctx, deps); return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false }; }, + convertLocal: ctx => { + convertLocalCalls.push(ctx); + return opts.convertLocal ? opts.convertLocal(ctx) : renderFake(ctx.frontmatter, extractMyNotes(ctx.body)); + }, }; - return { spec, syncCalls, resolveCalls }; + return { spec, syncCalls, resolveCalls, convertLocalCalls }; } const ACTIVE_NOTE = `--- @@ -111,6 +117,68 @@ describe('libraryFolderSync: tiering', () => { }); }); +describe('libraryFolderSync: local conversion (no-id notes)', () => { + test('unflagged no-id note: convertLocal differs from content -> written once, convertedLocal++, skippedNoId still counted, sync() never called', async () => { + const { spec, syncCalls } = makeFakeSpec({ convertLocal: () => 'CONVERTED' }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.convertedLocal).toBe(1); + expect(report.skippedNoId).toBe(1); + expect(writes.length).toBe(1); + expect(writes[0].content).toBe('CONVERTED'); + expect(syncCalls.length).toBe(0); + }); + + test('idempotence: second pass over already-converted content writes nothing, convertedLocal stays 0', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED' }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const first = await libraryFolderSync(spec, deps, {}); + expect(first.convertedLocal).toBe(1); + expect(writes.length).toBe(1); + + const second = makeDeps([{ path: 'A.md', content: writes[0].content }]); + const report2 = await libraryFolderSync(spec, second.deps, {}); + expect(second.writes.length).toBe(0); + expect(report2.convertedLocal).toBe(0); + }); + + test('convertLocal output identical to existing content -> no write, convertedLocal stays 0', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => NO_ID_NOTE }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.convertedLocal).toBe(0); + expect(writes.length).toBe(0); + }); + + test('no_resolve-flagged no-id note: still converted+written, but excluded from skippedNoId (no_resolve log line stays)', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED' }); + const flagged = NO_ID_NOTE.replace('fake_id: ', 'fake_id: \nno_resolve: true'); + const logs: string[] = []; + const { deps, writes } = makeDeps([{ path: 'A.md', content: flagged }]); + deps.log = (m: string) => logs.push(m); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.convertedLocal).toBe(1); + expect(report.skippedNoId).toBe(0); + expect(writes.length).toBe(1); + expect(logs.some(l => l.includes('no_resolve flag set, skipping sync: A.md'))).toBe(true); + }); + + test('dryRun: convertedLocal counted but no writeNote call', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED' }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderSync(spec, deps, { dryRun: true }); + expect(report.convertedLocal).toBe(1); + expect(writes.length).toBe(0); + }); + + test('no-id note does not sleep spec.throttleMs (no network involved)', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED', throttleMs: 777 }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(slept).not.toContain(777); + }); +}); + describe('libraryFolderSync: diff-on-write + dryRun', () => { test('diff-on-write: second pass on rendered output writes nothing', async () => { const { spec } = makeFakeSpec(); diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index 64e327e..18d75b2 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { buildGame, renderGame, parseSteamDate, gameSpec, type GameRecord } from 'packages/obsidian/src/library/game'; +import { buildGame, buildGameLocal, renderGame, parseSteamDate, gameSpec, type GameRecord } from 'packages/obsidian/src/library/game'; import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; import steamFixture from 'tests/fixtures/steam-appdetails.json'; import rawgFixture from 'tests/fixtures/rawg-game.json'; @@ -540,3 +540,91 @@ describe('gameSpec.sync — rawg enrich', () => { expect(result).toBeNull(); }); }); + +describe('buildGameLocal: pure prev-only mapper (no API payload)', () => { + test('empty prev + filename fallback -> title from filename, everything else empty/null', () => { + const r = buildGameLocal({}, 'Avatar - Frontiers of Pandora.md'); + expect(r.title).toBe('Avatar - Frontiers of Pandora'); + expect(r.playStatus).toBe('Unplayed'); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + expect(r.developer).toEqual([]); + expect(r.publisher).toEqual([]); + expect(r.platforms).toEqual([]); + expect(r.genre).toEqual([]); + expect(r.releaseDate).toBe(''); + expect(r.metacritic).toBeNull(); + expect(r.steamAppid).toBe(''); + expect(r.rawgId).toBe(''); + expect(r.poster).toBeNull(); + expect(r.url).toBe(''); + expect(r.description).toBe(''); + }); + + test('prev title wins over filename', () => { + expect(buildGameLocal({ title: 'Avatar' }, 'X.md').title).toBe('Avatar'); + }); + + test('skeleton legacy `played` field converted via existing derive helper', () => { + expect(buildGameLocal({ played: 'true' }, 'X.md').playStatus).toBe('Played'); + expect(buildGameLocal({ played: 'false' }, 'X.md').playStatus).toBe('Unplayed'); + }); + + test('canonical `play_status` wins over legacy `played`', () => { + expect(buildGameLocal({ played: 'false', play_status: 'Playing' }, 'X.md').playStatus).toBe('Playing'); + }); + + test('carries whatever id fields prev already has (steam_appid, rawg_id)', () => { + const r = buildGameLocal({ steam_appid: '2379780', rawg_id: '4200' }, 'X.md'); + expect(r.steamAppid).toBe('2379780'); + expect(r.rawgId).toBe('4200'); + }); + + test('existing url carried forward untouched, no synthetic Steam/RAWG link built', () => { + const r = buildGameLocal({ url: 'https://store.steampowered.com/app/2379780/Avatar/' }, 'X.md'); + expect(r.url).toBe('https://store.steampowered.com/app/2379780/Avatar/'); + }); +}); + +describe('gameSpec.convertLocal: no-network canonical conversion for id-less notes', () => { + test('stock-skeleton note -> canonical game_item shape, title falls back to filename', () => { + const fm = { type: 'game', played: 'true', personalRating: '3' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\nsome notes', filename: 'Avatar.md' }; + const content = gameSpec.convertLocal(ctx); + expect(content).toContain('type: game_item'); + expect(content).toContain('title: Avatar'); + expect(content).toContain('play_status: Played'); + expect(content).toContain('rating: 3'); + expect(content).toContain('## My Notes'); + expect(content).toContain('some notes'); + }); + + test('preserves custom sections through conversion', () => { + const ctx: LibraryNoteCtx = { + frontmatter: {}, + body: '## Mods\n\nreshade\n\n## My Notes\n\nkeep me', + filename: 'X.md', + }; + const content = gameSpec.convertLocal(ctx); + expect(content).toContain('## Mods'); + expect(content).toContain('reshade'); + expect(content).toContain('keep me'); + }); + + test('round-trip idempotence: re-running convertLocal on its own output yields byte-identical content', () => { + const fm = { type: 'game', played: 'false', personalRating: '', url: 'https://store.steampowered.com/app/2379780/Avatar/' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: 'Avatar.md' }; + const once = gameSpec.convertLocal(ctx); + const { frontmatter: fm2, body: body2 } = (() => { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(once)!; + const f: Record = {}; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) f[mm[1]] = mm[2].trim(); + } + return { frontmatter: f, body: m[2] }; + })(); + const twice = gameSpec.convertLocal({ frontmatter: fm2, body: body2, filename: 'Avatar.md' }); + expect(twice).toBe(once); + }); +}); diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index c495e75..53fd989 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { buildManga, buildMangaFromAniList, renderManga, mangaSpec, type MangaRecord } from 'packages/obsidian/src/library/manga'; +import { buildManga, buildMangaFromAniList, buildMangaLocal, renderManga, mangaSpec, type MangaRecord } from 'packages/obsidian/src/library/manga'; import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; import jikanFixture from 'tests/fixtures/jikan-manga-csm.json'; @@ -1089,3 +1089,103 @@ describe('mangaSpec.sync — AniList primary enrich', () => { expect(deps.notifyCalls).toEqual(['«Chainsaw Man» ch. 214 out']); }); }); + +describe('buildMangaLocal: pure prev-only mapper (no API payload)', () => { + test('empty prev + filename fallback -> title from filename, everything else empty/null', () => { + const r = buildMangaLocal({}, 'Berserk.md'); + expect(r.title).toBe('Berserk'); + expect(r.readStatus).toBe('Unread'); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + expect(r.chapters).toBeNull(); + expect(r.volumes).toBeNull(); + expect(r.status).toBe(''); + expect(r.authors).toEqual([]); + expect(r.genre).toEqual([]); + expect(r.score).toBeNull(); + expect(r.publishedFrom).toBeNull(); + expect(r.publishedTo).toBeNull(); + expect(r.malId).toBe(''); + expect(r.anilistId).toBe(''); + expect(r.mangadexId).toBe(''); + expect(r.rss).toBe(''); + expect(r.poster).toBeNull(); + expect(r.url).toBe(''); + expect(r.synopsis).toBe(''); + }); + + test('prev title wins over filename', () => { + expect(buildMangaLocal({ title: 'Chainsaw Man' }, 'Berserk.md').title).toBe('Chainsaw Man'); + }); + + test('skeleton legacy fields (read/personalRating) converted via existing derive helpers', () => { + const r = buildMangaLocal({ read: 'true', personalRating: '4' }, 'X.md'); + expect(r.readStatus).toBe('Read'); + expect(r.rating).toBe('4'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + }); + + test('carries whatever id/tracking fields prev already has (mal_id, anilist_id, mangadex_id, rss, last_read_chapter, latest_chapter, last_chapter_date)', () => { + const prev = { + mal_id: '116778', + anilist_id: '105778', + mangadex_id: 'abc-123', + rss: 'https://example.com/feed.xml', + last_read_chapter: '150', + latest_chapter: '213', + last_chapter_date: '2026-07-16', + }; + const r = buildMangaLocal(prev, 'X.md'); + expect(r.malId).toBe('116778'); + expect(r.anilistId).toBe('105778'); + expect(r.mangadexId).toBe('abc-123'); + expect(r.rss).toBe('https://example.com/feed.xml'); + expect(r.lastReadChapter).toBe('150'); + expect(r.latestChapter).toBe(213); + expect(r.lastChapterDate).toBe('2026-07-16'); + expect(r.url).toBe('https://myanimelist.net/manga/116778'); + }); +}); + +describe('mangaSpec.convertLocal: no-network canonical conversion for id-less notes', () => { + test('stock-skeleton note -> canonical manga_item shape, title falls back to filename', () => { + const fm = { type: 'comicManga', read: 'true', personalRating: '3' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\nsome notes', filename: 'Ghostblade.md' }; + const content = mangaSpec.convertLocal(ctx); + expect(content).toContain('type: manga_item'); + expect(content).toContain('title: Ghostblade'); + expect(content).toContain('read_status: Read'); + expect(content).toContain('rating: 3'); + expect(content).toContain('## My Notes'); + expect(content).toContain('some notes'); + }); + + test('preserves custom sections through conversion', () => { + const ctx: LibraryNoteCtx = { + frontmatter: {}, + body: '## Watch Order\n\nvol 1 first\n\n## My Notes\n\nkeep me', + filename: 'X.md', + }; + const content = mangaSpec.convertLocal(ctx); + expect(content).toContain('## Watch Order'); + expect(content).toContain('vol 1 first'); + expect(content).toContain('keep me'); + }); + + test('round-trip idempotence: re-running convertLocal on its own output yields byte-identical content', () => { + const fm = { type: 'comicManga', read: 'false', personalRating: '' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: 'Ghostblade.md' }; + const once = mangaSpec.convertLocal(ctx); + const { frontmatter: fm2, body: body2 } = (() => { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(once)!; + const f: Record = {}; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) f[mm[1]] = mm[2].trim(); + } + return { frontmatter: f, body: m[2] }; + })(); + const twice = mangaSpec.convertLocal({ frontmatter: fm2, body: body2, filename: 'Ghostblade.md' }); + expect(twice).toBe(once); + }); +});