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 {
|
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 {
|
function emptyResolveReport(): LibraryResolveReport {
|
||||||
|
|
@ -176,6 +176,7 @@ export class LibraryController {
|
||||||
* success just because skippedNoData/errors were left out of the message. */
|
* success just because skippedNoData/errors were left out of the message. */
|
||||||
private buildSyncSummary(spec: MediaTypeSpec, mode: string, report: LibraryReport): string {
|
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`;
|
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.skippedStatic) msg += `, ${report.skippedStatic} static`;
|
||||||
if (report.skippedNoId) msg += `, ${report.skippedNoId} no-id`;
|
if (report.skippedNoId) msg += `, ${report.skippedNoId} no-id`;
|
||||||
if (report.skippedNoData) msg += `, ${report.skippedNoData} no-data (see console)`;
|
if (report.skippedNoData) msg += `, ${report.skippedNoData} no-data (see console)`;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface LibraryReport {
|
||||||
skippedNoId: number;
|
skippedNoId: number;
|
||||||
skippedStatic: number;
|
skippedStatic: number;
|
||||||
skippedNoData: number;
|
skippedNoData: number;
|
||||||
|
convertedLocal: number;
|
||||||
flipped: string[];
|
flipped: string[];
|
||||||
errors: { path: string; error: 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> {
|
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();
|
const notes = await deps.listNotes();
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
report.scanned++;
|
report.scanned++;
|
||||||
|
|
@ -68,6 +69,18 @@ export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngine
|
||||||
} else {
|
} else {
|
||||||
report.skippedNoId++;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (!opts.full && !spec.isActive(frontmatter)) {
|
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. */
|
/** Carries the legacy goodreads search link forward as a `## Links` entry across every re-render. */
|
||||||
function resolveGoodreadsLink(prev: Record<string, string>, body: string): string {
|
function resolveGoodreadsLink(prev: Record<string, string>, body: string): string {
|
||||||
const linksMatch = /##\s*Links\s*\n([\s\S]*?)(?=\n##\s|$)/.exec(body ?? '');
|
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);
|
const content = renderBook(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body), goodreadsUrl);
|
||||||
return { content, flipped: false }; // static spec -- no automation, never flips
|
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 {
|
export function renderComic(r: ComicRecord, myNotes: string, customSections: CustomSection[] = []): string {
|
||||||
const fm = [
|
const fm = [
|
||||||
'---',
|
'---',
|
||||||
|
|
@ -296,4 +331,9 @@ export const comicSpec: MediaTypeSpec = {
|
||||||
const content = renderComic(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
|
const content = renderComic(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
|
||||||
return { content, flipped };
|
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 {
|
export function renderGame(r: GameRecord, myNotes: string, customSections: CustomSection[] = []): string {
|
||||||
const fm = [
|
const fm = [
|
||||||
'---',
|
'---',
|
||||||
|
|
@ -315,4 +352,9 @@ export const gameSpec: MediaTypeSpec = {
|
||||||
|
|
||||||
return null; // steam_appid set, success:false, no rawg fallback available
|
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 `{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). */
|
* (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 {
|
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));
|
const content = renderManga(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
|
||||||
return { content, flipped };
|
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;
|
isActive(fm: Record<string, string>): boolean;
|
||||||
resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<ResolveOutcome | null>; // null = no match/error
|
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
|
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 {
|
export interface SpecDeps {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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 type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types';
|
||||||
import olFixture from 'tests/fixtures/openlibrary-search.json';
|
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' } });
|
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<string, string> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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 type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types';
|
||||||
import comicvineFixture from 'tests/fixtures/comicvine-volume.json';
|
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']);
|
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<string, string> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -449,6 +449,7 @@ describe('sync summary transparency', () => {
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
resolve: async () => null,
|
resolve: async () => null,
|
||||||
sync: async () => null,
|
sync: async () => null,
|
||||||
|
convertLocal: () => '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const report = await c.syncType(noDataSpec, false);
|
const report = await c.syncType(noDataSpec, false);
|
||||||
|
|
@ -478,6 +479,7 @@ describe('resolveType: interactive candidate picker (needsChoice)', () => {
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
resolve: async () => ({ candidates: CANDIDATES }),
|
resolve: async () => ({ candidates: CANDIDATES }),
|
||||||
sync: async () => null,
|
sync: async () => null,
|
||||||
|
convertLocal: () => '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeCandidateDeps(writes: { path: string; content: string }[]): LibraryEngineDeps {
|
function makeCandidateDeps(writes: { path: string; content: string }[]): LibraryEngineDeps {
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,14 @@ interface FakeSpecOptions {
|
||||||
isActive?(fm: Record<string, string>): boolean;
|
isActive?(fm: Record<string, string>): boolean;
|
||||||
sync?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType<MediaTypeSpec['sync']>;
|
sync?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType<MediaTypeSpec['sync']>;
|
||||||
resolve?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType<MediaTypeSpec['resolve']>;
|
resolve?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType<MediaTypeSpec['resolve']>;
|
||||||
|
convertLocal?(ctx: LibraryNoteCtx): string;
|
||||||
throttleMs?: number;
|
throttleMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFakeSpec(opts: FakeSpecOptions = {}) {
|
function makeFakeSpec(opts: FakeSpecOptions = {}) {
|
||||||
const syncCalls: LibraryNoteCtx[] = [];
|
const syncCalls: LibraryNoteCtx[] = [];
|
||||||
const resolveCalls: LibraryNoteCtx[] = [];
|
const resolveCalls: LibraryNoteCtx[] = [];
|
||||||
|
const convertLocalCalls: LibraryNoteCtx[] = [];
|
||||||
const spec: MediaTypeSpec = {
|
const spec: MediaTypeSpec = {
|
||||||
typeName: 'manga',
|
typeName: 'manga',
|
||||||
itemType: FAKE_ITEM_TYPE,
|
itemType: FAKE_ITEM_TYPE,
|
||||||
|
|
@ -38,8 +40,12 @@ function makeFakeSpec(opts: FakeSpecOptions = {}) {
|
||||||
if (opts.sync) return await opts.sync(ctx, deps);
|
if (opts.sync) return await opts.sync(ctx, deps);
|
||||||
return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false };
|
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 = `---
|
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', () => {
|
describe('libraryFolderSync: diff-on-write + dryRun', () => {
|
||||||
test('diff-on-write: second pass on rendered output writes nothing', async () => {
|
test('diff-on-write: second pass on rendered output writes nothing', async () => {
|
||||||
const { spec } = makeFakeSpec();
|
const { spec } = makeFakeSpec();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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 type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types';
|
||||||
import steamFixture from 'tests/fixtures/steam-appdetails.json';
|
import steamFixture from 'tests/fixtures/steam-appdetails.json';
|
||||||
import rawgFixture from 'tests/fixtures/rawg-game.json';
|
import rawgFixture from 'tests/fixtures/rawg-game.json';
|
||||||
|
|
@ -540,3 +540,91 @@ describe('gameSpec.sync — rawg enrich', () => {
|
||||||
expect(result).toBeNull();
|
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<string, string> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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 type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types';
|
||||||
import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine';
|
import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine';
|
||||||
import jikanFixture from 'tests/fixtures/jikan-manga-csm.json';
|
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']);
|
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<string, string> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue