feat(library): comic spec — comic vine + issue flip
This commit is contained in:
parent
cdc653e6e4
commit
d2a6940270
4 changed files with 769 additions and 0 deletions
261
packages/obsidian/src/library/comic.ts
Normal file
261
packages/obsidian/src/library/comic.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
|
||||||
|
import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse';
|
||||||
|
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
|
||||||
|
|
||||||
|
const COMICVINE_BASE = 'https://comicvine.gamespot.com/api';
|
||||||
|
|
||||||
|
export interface ComicRecord {
|
||||||
|
title: string;
|
||||||
|
readStatus: string;
|
||||||
|
rating: string;
|
||||||
|
ratingStars: string;
|
||||||
|
lastReadIssue: string;
|
||||||
|
latestIssue: number | null;
|
||||||
|
issues: number | null;
|
||||||
|
status: string;
|
||||||
|
publisher: string;
|
||||||
|
people: string[];
|
||||||
|
startYear: string;
|
||||||
|
comicvineId: string;
|
||||||
|
poster: string | null;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumOrNull(raw: string | undefined): number | null {
|
||||||
|
const s = stripQuotes(raw);
|
||||||
|
if (!s || s === 'null') return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveReadStatus(prev: Record<string, string>): string {
|
||||||
|
const canonical = stripQuotes(prev['read_status']);
|
||||||
|
if (canonical) return canonical;
|
||||||
|
// skeleton conversion: legacy boolean `read` field
|
||||||
|
return stripQuotes(prev['read']) === 'true' ? 'Read' : 'Unread';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveRating(prev: Record<string, string>): { rating: string; ratingStars: string } {
|
||||||
|
const canonicalRating = stripQuotes(prev['rating']);
|
||||||
|
if (canonicalRating) return { rating: canonicalRating, ratingStars: stripQuotes(prev['rating_stars']) };
|
||||||
|
// skeleton conversion: legacy numeric `personalRating` field -> N stars
|
||||||
|
const legacy = Number(stripQuotes(prev['personalRating']));
|
||||||
|
if (Number.isFinite(legacy) && legacy > 0) {
|
||||||
|
return { rating: String(legacy), ratingStars: '⭐️'.repeat(legacy) };
|
||||||
|
}
|
||||||
|
return { rating: '0', ratingStars: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* status is a manual/passthrough field -- Comic Vine's volume payload carries no reliable
|
||||||
|
* "still publishing" signal (no last-issue date), so we deliberately do NOT invent any
|
||||||
|
* date-based heuristic here. Whatever the note already has wins; only a never-synced note
|
||||||
|
* (empty status) defaults to 'Ongoing'.
|
||||||
|
*/
|
||||||
|
function deriveStatus(prev: Record<string, string>): string {
|
||||||
|
return stripQuotes(prev['status']) || 'Ongoing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comic Vine `description` -> plain text: split on <p> blocks (whole string treated as one
|
||||||
|
* block when there are none), strip remaining tags, decode entities, collapse whitespace,
|
||||||
|
* keep only the first 2 paragraphs.
|
||||||
|
*/
|
||||||
|
export function htmlToPlainText(html: string): string {
|
||||||
|
if (!html) return '';
|
||||||
|
const paraMatches = html.match(/<p[^>]*>([\s\S]*?)<\/p>/gi);
|
||||||
|
const blocks = paraMatches && paraMatches.length ? paraMatches : [html];
|
||||||
|
const paragraphs = blocks
|
||||||
|
.map(b => decodeEntities(b.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return paragraphs.slice(0, 2).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure mapper: Comic Vine `/volume/{id}/` payload (the unwrapped `results` object) + prev
|
||||||
|
* frontmatter -> canonical ComicRecord. User-managed fields (read_status, rating,
|
||||||
|
* rating_stars, last_read_issue, status) are preserved from prev (with legacy-skeleton
|
||||||
|
* fallback); latest_issue is carried from prev as-is here -- sync() alone decides whether a
|
||||||
|
* new last_issue.issue_number supersedes it (and whether that flips read_status). Everything
|
||||||
|
* else is freshly derived from the Comic Vine response on every call.
|
||||||
|
*/
|
||||||
|
export function buildComic(cv: any, prev: Record<string, string>): ComicRecord {
|
||||||
|
const title: string = cv.name ?? '';
|
||||||
|
const { rating, ratingStars } = deriveRating(prev);
|
||||||
|
const readStatus = deriveReadStatus(prev);
|
||||||
|
const lastReadIssue = stripQuotes(prev['last_read_issue']);
|
||||||
|
const comicvineId = cv.id != null ? String(cv.id) : stripQuotes(prev['comicvine_id']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
readStatus,
|
||||||
|
rating,
|
||||||
|
ratingStars,
|
||||||
|
lastReadIssue,
|
||||||
|
latestIssue: parseNumOrNull(prev['latest_issue']),
|
||||||
|
issues: typeof cv.count_of_issues === 'number' ? cv.count_of_issues : null,
|
||||||
|
status: deriveStatus(prev),
|
||||||
|
publisher: cv.publisher?.name ?? '',
|
||||||
|
people: Array.isArray(cv.people) ? cv.people.map((p: any) => p.name).filter(Boolean) : [],
|
||||||
|
startYear: cv.start_year != null ? String(cv.start_year) : '',
|
||||||
|
comicvineId,
|
||||||
|
poster: cv.image?.original_url ?? null,
|
||||||
|
url: cv.site_detail_url ?? '',
|
||||||
|
description: htmlToPlainText(cv.description ?? ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderComic(r: ComicRecord, myNotes: string, customSections: CustomSection[] = []): string {
|
||||||
|
const fm = [
|
||||||
|
'---',
|
||||||
|
'type: comic_item',
|
||||||
|
`title: ${yamlScalar(r.title)}`,
|
||||||
|
`read_status: ${r.readStatus}`,
|
||||||
|
`rating: ${r.rating}`,
|
||||||
|
`rating_stars: ${r.ratingStars}`,
|
||||||
|
`last_read_issue: ${r.lastReadIssue}`,
|
||||||
|
`latest_issue: ${r.latestIssue ?? 'null'}`,
|
||||||
|
`issues: ${r.issues ?? 'null'}`,
|
||||||
|
`status: ${r.status}`,
|
||||||
|
`publisher: ${yamlScalar(r.publisher)}`,
|
||||||
|
`people: ${yamlList(r.people)}`,
|
||||||
|
`start_year: ${r.startYear}`,
|
||||||
|
`comicvine_id: ${r.comicvineId}`,
|
||||||
|
`poster: ${quotedOrNull(r.poster)}`,
|
||||||
|
`url: ${quotedOrNull(r.url)}`,
|
||||||
|
'tags: [comics, comic]',
|
||||||
|
'---',
|
||||||
|
];
|
||||||
|
|
||||||
|
const b: string[] = ['', `# ${r.title}`, ''];
|
||||||
|
if (r.poster) b.push(``, '');
|
||||||
|
const meta = ['**Comic**', ...[r.status, r.startYear].filter(x => x)];
|
||||||
|
b.push(meta.join(' · '), '');
|
||||||
|
b.push(`**Read Status:** ${r.readStatus}`);
|
||||||
|
if (r.lastReadIssue) {
|
||||||
|
const denom = r.latestIssue ?? r.issues ?? '?';
|
||||||
|
b.push(`**Progress:** issue ${r.lastReadIssue} / ${denom}`);
|
||||||
|
}
|
||||||
|
b.push('');
|
||||||
|
if (r.description) b.push('## Synopsis', r.description, '');
|
||||||
|
const facts: string[] = [];
|
||||||
|
if (r.publisher) facts.push(`**Publisher:** ${r.publisher}`);
|
||||||
|
if (r.people.length) facts.push(`**Creators:** ${r.people.join(', ')}`);
|
||||||
|
if (facts.length) b.push(...facts, '');
|
||||||
|
const links: string[] = [];
|
||||||
|
if (r.url) links.push(`- [Comic Vine](${r.url})`);
|
||||||
|
if (links.length) b.push('## Links', ...links, '');
|
||||||
|
for (const s of customSections) b.push(`## ${s.heading}`, s.content, '');
|
||||||
|
b.push('## My Notes', '', myNotes);
|
||||||
|
if (myNotes) b.push('');
|
||||||
|
return fm.join('\n') + '\n' + b.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVolumeResults(query: string, key: string, deps: SpecDeps): Promise<any[]> {
|
||||||
|
const qs = new URLSearchParams({ api_key: key, format: 'json', filter: `name:${query}`, limit: '10' });
|
||||||
|
const res = await deps.http(`${COMICVINE_BASE}/volumes/?${qs.toString()}`, {});
|
||||||
|
return Array.isArray(res?.results) ? res.results : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const comicSpec: MediaTypeSpec = {
|
||||||
|
typeName: 'comic',
|
||||||
|
itemType: 'comic_item',
|
||||||
|
folderSettingKey: 'libraryComicFolder',
|
||||||
|
enabledSettingKey: 'libraryComicEnabled',
|
||||||
|
throttleMs: 350,
|
||||||
|
|
||||||
|
hasId(fm: Record<string, string>): boolean {
|
||||||
|
return !!stripQuotes(fm['comicvine_id']);
|
||||||
|
},
|
||||||
|
|
||||||
|
isActive(fm: Record<string, string>): boolean {
|
||||||
|
if (!stripQuotes(fm['comicvine_id'])) return true; // never enriched -> needs first pass
|
||||||
|
if (stripQuotes(fm['status']) === 'Ongoing') return true;
|
||||||
|
if (stripQuotes(fm['read_status']) === 'Reading') return true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<Record<string, string> | null> {
|
||||||
|
const key = deps.getKey('comicvine');
|
||||||
|
if (!key) {
|
||||||
|
deps.log('comic resolve: no Comic Vine key configured, skipping id lookup');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, '');
|
||||||
|
if (!query) return null;
|
||||||
|
try {
|
||||||
|
const results = await fetchVolumeResults(query, key, deps);
|
||||||
|
if (results.length === 0) return null;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const exacts = results.filter(r => String(r.name ?? '').toLowerCase() === q);
|
||||||
|
const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null;
|
||||||
|
return pick && pick.id != null ? { comicvine_id: String(pick.id) } : null;
|
||||||
|
} catch (e) {
|
||||||
|
deps.log(`comic resolve failed for "${query}": ${String(e)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> {
|
||||||
|
const fm = ctx.frontmatter;
|
||||||
|
const id = stripQuotes(fm['comicvine_id']);
|
||||||
|
if (!id) return null; // needs resolve() first
|
||||||
|
|
||||||
|
const key = deps.getKey('comicvine');
|
||||||
|
if (!key) {
|
||||||
|
deps.log(`comic sync: no Comic Vine key configured, skipping comicvine_id ${id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: any;
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({ api_key: key, format: 'json' });
|
||||||
|
json = await deps.http(`${COMICVINE_BASE}/volume/4050-${id}/?${qs.toString()}`, {});
|
||||||
|
} catch (e) {
|
||||||
|
deps.log(`comic comicvine fetch failed (comicvine_id ${id}): ${String(e)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = json?.results;
|
||||||
|
if (!result || !result.name || (result.id != null && String(result.id) !== id)) {
|
||||||
|
// identity-guard: never silently swap to a different volume's data; leave the note
|
||||||
|
// untouched (id fields preserved) when the response is missing or mismatched.
|
||||||
|
deps.log(`comic comicvine volume fetch returned no usable data for comicvine_id ${id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = buildComic(result, fm);
|
||||||
|
const prevLatestIssue = parseNumOrNull(fm['latest_issue']);
|
||||||
|
const rawIssueNumber = result.last_issue?.issue_number;
|
||||||
|
const candidate = rawIssueNumber != null ? parseFloat(String(rawIssueNumber)) : NaN;
|
||||||
|
|
||||||
|
let flipped = false;
|
||||||
|
if (Number.isFinite(candidate)) {
|
||||||
|
const isUpdate = prevLatestIssue === null || candidate > prevLatestIssue;
|
||||||
|
if (isUpdate) {
|
||||||
|
record.latestIssue = candidate;
|
||||||
|
if (record.readStatus === 'Read') {
|
||||||
|
record.readStatus = 'Unread';
|
||||||
|
flipped = true;
|
||||||
|
deps.notify(`«${record.title}» issue ${candidate} out`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// non-numeric or missing last_issue.issue_number -> skip flip, keep prev latest_issue
|
||||||
|
// (already set by buildComic above, untouched here)
|
||||||
|
|
||||||
|
const content = renderComic(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body));
|
||||||
|
return { content, flipped };
|
||||||
|
},
|
||||||
|
};
|
||||||
41
tests/fixtures/canonical-comic.md
vendored
Normal file
41
tests/fixtures/canonical-comic.md
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
type: comic_item
|
||||||
|
title: Absolute Batman
|
||||||
|
read_status: Reading
|
||||||
|
rating: 4
|
||||||
|
rating_stars: ⭐️⭐️⭐️⭐️
|
||||||
|
last_read_issue: 8
|
||||||
|
latest_issue: 10
|
||||||
|
issues: 10
|
||||||
|
status: Ongoing
|
||||||
|
publisher: DC Comics
|
||||||
|
people: [Scott Snyder, Nick Dragotta]
|
||||||
|
start_year: 2024
|
||||||
|
comicvine_id: 195824
|
||||||
|
poster: "https://comicvine.gamespot.com/a/uploads/original/11/absolute-batman.jpg"
|
||||||
|
url: "https://comicvine.gamespot.com/absolute-batman/4050-195824/"
|
||||||
|
tags: [comics, comic]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Absolute Batman
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Comic** · Ongoing · 2024
|
||||||
|
|
||||||
|
**Read Status:** Reading
|
||||||
|
**Progress:** issue 8 / 10
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
Batman faces a terrifying new criminal underworld as Gotham City's elite turn against him in this bold reimagining.
|
||||||
|
|
||||||
|
Written by Scott Snyder with art by Nick Dragotta, the series reinvents Bruce Wayne's origin from the ground up.
|
||||||
|
|
||||||
|
**Publisher:** DC Comics
|
||||||
|
**Creators:** Scott Snyder, Nick Dragotta
|
||||||
|
|
||||||
|
## Links
|
||||||
|
- [Comic Vine](https://comicvine.gamespot.com/absolute-batman/4050-195824/)
|
||||||
|
|
||||||
|
## My Notes
|
||||||
|
|
||||||
18
tests/fixtures/comicvine-volume.json
vendored
Normal file
18
tests/fixtures/comicvine-volume.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"results": {
|
||||||
|
"id": 195824,
|
||||||
|
"name": "Absolute Batman",
|
||||||
|
"publisher": { "id": 10, "name": "DC Comics" },
|
||||||
|
"count_of_issues": 10,
|
||||||
|
"last_issue": { "issue_number": "10", "name": "Zoo Year, Part Ten" },
|
||||||
|
"start_year": "2024",
|
||||||
|
"image": { "original_url": "https://comicvine.gamespot.com/a/uploads/original/11/absolute-batman.jpg" },
|
||||||
|
"description": "<p>Batman faces a terrifying new criminal underworld as Gotham City's elite turn against him in this bold reimagining.</p><p>Written by Scott Snyder with art by Nick Dragotta, the series reinvents Bruce Wayne's origin from the ground up.</p><p>This paragraph exists only to verify the two-paragraph cap trims it from the rendered synopsis.</p>",
|
||||||
|
"site_detail_url": "https://comicvine.gamespot.com/absolute-batman/4050-195824/",
|
||||||
|
"people": [
|
||||||
|
{ "id": 1, "name": "Scott Snyder" },
|
||||||
|
{ "id": 2, "name": "Nick Dragotta" }
|
||||||
|
],
|
||||||
|
"date_last_updated": "2026-07-20 10:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
449
tests/library-comic.test.ts
Normal file
449
tests/library-comic.test.ts
Normal file
|
|
@ -0,0 +1,449 @@
|
||||||
|
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 type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types';
|
||||||
|
import comicvineFixture from 'tests/fixtures/comicvine-volume.json';
|
||||||
|
|
||||||
|
const CV_RESULT = comicvineFixture.results;
|
||||||
|
|
||||||
|
const EMPTY_PREV: Record<string, string> = {};
|
||||||
|
|
||||||
|
function makeDeps(overrides: Partial<SpecDeps> = {}): SpecDeps & { notifyCalls: string[]; logCalls: string[] } {
|
||||||
|
const notifyCalls: string[] = [];
|
||||||
|
const logCalls: string[] = [];
|
||||||
|
return {
|
||||||
|
http: async () => ({}),
|
||||||
|
httpText: async () => '',
|
||||||
|
getKey: () => '',
|
||||||
|
log: (msg: string) => {
|
||||||
|
logCalls.push(msg);
|
||||||
|
},
|
||||||
|
notify: (msg: string) => {
|
||||||
|
notifyCalls.push(msg);
|
||||||
|
},
|
||||||
|
notifyCalls,
|
||||||
|
logCalls,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxFor(fm: Record<string, string>, body = '## My Notes\n\n'): LibraryNoteCtx {
|
||||||
|
return { frontmatter: fm, body, filename: 'Absolute Batman.md' };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('htmlToPlainText', () => {
|
||||||
|
test('strips tags, decodes entities, caps at 2 paragraphs', () => {
|
||||||
|
const html = '<p>First & foremost.</p><p>Second paragraph.</p><p>Third dropped.</p>';
|
||||||
|
expect(htmlToPlainText(html)).toBe('First & foremost.\n\nSecond paragraph.');
|
||||||
|
});
|
||||||
|
test('no <p> tags -> whole string treated as one block', () => {
|
||||||
|
expect(htmlToPlainText('<b>Bold</b> and plain text.')).toBe('Bold and plain text.');
|
||||||
|
});
|
||||||
|
test('decodes < > " ' ', () => {
|
||||||
|
expect(htmlToPlainText('<p><tag> "quoted" it's fine</p>')).toBe(`<tag> "quoted" it's fine`);
|
||||||
|
});
|
||||||
|
test('empty string -> empty string', () => {
|
||||||
|
expect(htmlToPlainText('')).toBe('');
|
||||||
|
});
|
||||||
|
test('collapses internal whitespace from stripped tags', () => {
|
||||||
|
expect(htmlToPlainText('<p>Line one<br/>Line two</p>')).toBe('Line one Line two');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildComic field mapping', () => {
|
||||||
|
const r = buildComic(CV_RESULT, EMPTY_PREV);
|
||||||
|
test('core fields', () => {
|
||||||
|
expect(r.title).toBe('Absolute Batman');
|
||||||
|
expect(r.publisher).toBe('DC Comics');
|
||||||
|
expect(r.issues).toBe(10);
|
||||||
|
expect(r.startYear).toBe('2024');
|
||||||
|
expect(r.comicvineId).toBe('195824');
|
||||||
|
expect(r.poster).toBe('https://comicvine.gamespot.com/a/uploads/original/11/absolute-batman.jpg');
|
||||||
|
expect(r.url).toBe('https://comicvine.gamespot.com/absolute-batman/4050-195824/');
|
||||||
|
});
|
||||||
|
test('people mapped from people[].name', () => {
|
||||||
|
expect(r.people).toEqual(['Scott Snyder', 'Nick Dragotta']);
|
||||||
|
});
|
||||||
|
test('description HTML -> plain text, capped at 2 paragraphs', () => {
|
||||||
|
expect(r.description).toBe(
|
||||||
|
"Batman faces a terrifying new criminal underworld as Gotham City's elite turn against him in this bold reimagining.\n\nWritten by Scott Snyder with art by Nick Dragotta, the series reinvents Bruce Wayne's origin from the ground up.",
|
||||||
|
);
|
||||||
|
expect(r.description).not.toContain('two-paragraph cap');
|
||||||
|
});
|
||||||
|
test('no publisher -> empty string', () => {
|
||||||
|
expect(buildComic({ ...CV_RESULT, publisher: undefined }, EMPTY_PREV).publisher).toBe('');
|
||||||
|
});
|
||||||
|
test('no count_of_issues -> issues null', () => {
|
||||||
|
expect(buildComic({ ...CV_RESULT, count_of_issues: undefined }, EMPTY_PREV).issues).toBeNull();
|
||||||
|
});
|
||||||
|
test('no image -> poster null', () => {
|
||||||
|
expect(buildComic({ ...CV_RESULT, image: undefined }, EMPTY_PREV).poster).toBeNull();
|
||||||
|
});
|
||||||
|
test('no people -> empty array', () => {
|
||||||
|
expect(buildComic({ ...CV_RESULT, people: undefined }, EMPTY_PREV).people).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildComic user-field preservation', () => {
|
||||||
|
test('read_status defaults to Unread', () => {
|
||||||
|
expect(buildComic(CV_RESULT, EMPTY_PREV).readStatus).toBe('Unread');
|
||||||
|
});
|
||||||
|
test('read_status carried from prev', () => {
|
||||||
|
expect(buildComic(CV_RESULT, { read_status: 'Reading' }).readStatus).toBe('Reading');
|
||||||
|
});
|
||||||
|
test('rating/rating_stars carried from prev', () => {
|
||||||
|
const prev = { rating: '4', rating_stars: '⭐️⭐️⭐️⭐️' };
|
||||||
|
const r = buildComic(CV_RESULT, prev);
|
||||||
|
expect(r.rating).toBe('4');
|
||||||
|
expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️');
|
||||||
|
});
|
||||||
|
test('last_read_issue carried from prev', () => {
|
||||||
|
expect(buildComic(CV_RESULT, { last_read_issue: '8' }).lastReadIssue).toBe('8');
|
||||||
|
});
|
||||||
|
test('latest_issue carried from prev verbatim (sync() alone updates it)', () => {
|
||||||
|
expect(buildComic(CV_RESULT, { latest_issue: '9' }).latestIssue).toBe(9);
|
||||||
|
});
|
||||||
|
test('status passthrough from prev', () => {
|
||||||
|
expect(buildComic(CV_RESULT, { status: 'Hiatus' }).status).toBe('Hiatus');
|
||||||
|
});
|
||||||
|
test('status defaults to Ongoing when prev has none', () => {
|
||||||
|
expect(buildComic(CV_RESULT, EMPTY_PREV).status).toBe('Ongoing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildComic skeleton conversion', () => {
|
||||||
|
test('read: true -> read_status Read', () => {
|
||||||
|
expect(buildComic(CV_RESULT, { read: 'true', personalRating: '' }).readStatus).toBe('Read');
|
||||||
|
});
|
||||||
|
test('read: false -> read_status Unread', () => {
|
||||||
|
expect(buildComic(CV_RESULT, { read: 'false', personalRating: '' }).readStatus).toBe('Unread');
|
||||||
|
});
|
||||||
|
test('personalRating 3 -> rating 3 + 3 stars', () => {
|
||||||
|
const r = buildComic(CV_RESULT, { read: 'false', personalRating: '3' });
|
||||||
|
expect(r.rating).toBe('3');
|
||||||
|
expect(r.ratingStars).toBe('⭐️⭐️⭐️');
|
||||||
|
});
|
||||||
|
test('empty personalRating -> rating 0, no stars', () => {
|
||||||
|
const r = buildComic(CV_RESULT, { read: 'false', personalRating: '' });
|
||||||
|
expect(r.rating).toBe('0');
|
||||||
|
expect(r.ratingStars).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderComic golden', () => {
|
||||||
|
const RECORD: ComicRecord = {
|
||||||
|
title: 'Absolute Batman',
|
||||||
|
readStatus: 'Reading',
|
||||||
|
rating: '4',
|
||||||
|
ratingStars: '⭐️⭐️⭐️⭐️',
|
||||||
|
lastReadIssue: '8',
|
||||||
|
latestIssue: 10,
|
||||||
|
issues: 10,
|
||||||
|
status: 'Ongoing',
|
||||||
|
publisher: 'DC Comics',
|
||||||
|
people: ['Scott Snyder', 'Nick Dragotta'],
|
||||||
|
startYear: '2024',
|
||||||
|
comicvineId: '195824',
|
||||||
|
poster: 'https://comicvine.gamespot.com/a/uploads/original/11/absolute-batman.jpg',
|
||||||
|
url: 'https://comicvine.gamespot.com/absolute-batman/4050-195824/',
|
||||||
|
description:
|
||||||
|
"Batman faces a terrifying new criminal underworld as Gotham City's elite turn against him in this bold reimagining.\n\nWritten by Scott Snyder with art by Nick Dragotta, the series reinvents Bruce Wayne's origin from the ground up.",
|
||||||
|
};
|
||||||
|
|
||||||
|
test('matches canonical comic fixture byte-for-byte', () => {
|
||||||
|
const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-comic.md'), 'utf-8');
|
||||||
|
expect(renderComic(RECORD, '', [])).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom section (Collection) placed after Links, before My Notes', () => {
|
||||||
|
const out = renderComic(RECORD, '', [{ heading: 'Collection', content: 'Part of [[Comics]]' }]);
|
||||||
|
const linksIdx = out.indexOf('## Links');
|
||||||
|
const collectionIdx = out.indexOf('## Collection');
|
||||||
|
const myNotesIdx = out.indexOf('## My Notes');
|
||||||
|
expect(collectionIdx).toBeGreaterThan(linksIdx);
|
||||||
|
expect(myNotesIdx).toBeGreaterThan(collectionIdx);
|
||||||
|
expect(out).toContain('## Collection\nPart of [[Comics]]\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('My Notes content preserved', () => {
|
||||||
|
const out = renderComic(RECORD, 'love this reboot');
|
||||||
|
expect(out).toContain('## My Notes\n\nlove this reboot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no last_read_issue -> Progress line omitted', () => {
|
||||||
|
const r = { ...RECORD, lastReadIssue: '' };
|
||||||
|
expect(renderComic(r, '')).not.toContain('**Progress:**');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Progress denominator falls back to issues when latest_issue null', () => {
|
||||||
|
const r = { ...RECORD, latestIssue: null, issues: 12 };
|
||||||
|
expect(renderComic(r, '')).toContain('**Progress:** issue 8 / 12');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Progress denominator falls back to ? when both null', () => {
|
||||||
|
const r = { ...RECORD, latestIssue: null, issues: null };
|
||||||
|
expect(renderComic(r, '')).toContain('**Progress:** issue 8 / ?');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no poster -> poster line omitted', () => {
|
||||||
|
const r = { ...RECORD, poster: null };
|
||||||
|
expect(renderComic(r, '')).not.toContain('![poster');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no description -> Synopsis section omitted', () => {
|
||||||
|
const r = { ...RECORD, description: '' };
|
||||||
|
expect(renderComic(r, '')).not.toContain('## Synopsis');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no publisher/people -> fact lines omitted entirely', () => {
|
||||||
|
const r = { ...RECORD, publisher: '', people: [] };
|
||||||
|
const out = renderComic(r, '');
|
||||||
|
expect(out).not.toContain('**Publisher:**');
|
||||||
|
expect(out).not.toContain('**Creators:**');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comicSpec.hasId', () => {
|
||||||
|
test('comicvine_id set -> true', () => expect(comicSpec.hasId({ comicvine_id: '195824' })).toBe(true));
|
||||||
|
test('comicvine_id empty -> false', () => expect(comicSpec.hasId({ comicvine_id: '' })).toBe(false));
|
||||||
|
test('comicvine_id missing -> false', () => expect(comicSpec.hasId({})).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comicSpec.isActive', () => {
|
||||||
|
test('comicvine_id empty -> active (needs first pass)', () => {
|
||||||
|
expect(comicSpec.isActive({})).toBe(true);
|
||||||
|
});
|
||||||
|
test('status Ongoing -> active', () => {
|
||||||
|
expect(comicSpec.isActive({ comicvine_id: '195824', status: 'Ongoing', read_status: 'Unread' })).toBe(true);
|
||||||
|
});
|
||||||
|
test('read_status Reading -> active regardless of status', () => {
|
||||||
|
expect(comicSpec.isActive({ comicvine_id: '195824', status: 'Ended', read_status: 'Reading' })).toBe(true);
|
||||||
|
});
|
||||||
|
test('status Ended + read_status Read -> static', () => {
|
||||||
|
expect(comicSpec.isActive({ comicvine_id: '195824', status: 'Ended', read_status: 'Read' })).toBe(false);
|
||||||
|
});
|
||||||
|
test('status Ended + read_status Unread -> static', () => {
|
||||||
|
expect(comicSpec.isActive({ comicvine_id: '195824', status: 'Ended', read_status: 'Unread' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comicSpec.resolve', () => {
|
||||||
|
test('no key configured -> null, logged, no network attempted', async () => {
|
||||||
|
let httpCalled = false;
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => {
|
||||||
|
httpCalled = true;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
getKey: () => '',
|
||||||
|
});
|
||||||
|
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(httpCalled).toBe(false);
|
||||||
|
expect(deps.logCalls.some(m => m.toLowerCase().includes('key'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unique exact volume name match -> accepted', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => ({ results: [{ id: 195824, name: 'Absolute Batman' }] }),
|
||||||
|
getKey: () => 'cvkey',
|
||||||
|
});
|
||||||
|
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||||
|
expect(result).toEqual({ comicvine_id: '195824' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no exact match, sole result -> accepted', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => ({ results: [{ id: 999, name: 'Absolute Batman: Zoo Year' }] }),
|
||||||
|
getKey: () => 'cvkey',
|
||||||
|
});
|
||||||
|
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||||
|
expect(result).toEqual({ comicvine_id: '999' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ambiguous (multiple results, no exact match) -> null', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => ({
|
||||||
|
results: [
|
||||||
|
{ id: 1, name: 'Batman' },
|
||||||
|
{ id: 2, name: 'Batman Beyond' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getKey: () => 'cvkey',
|
||||||
|
});
|
||||||
|
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no results -> null', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => ({ results: [] }), getKey: () => 'cvkey' });
|
||||||
|
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('http throws -> log, return null (no throw)', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => {
|
||||||
|
throw new Error('down');
|
||||||
|
},
|
||||||
|
getKey: () => 'cvkey',
|
||||||
|
});
|
||||||
|
const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(deps.logCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no title, no filename fallback -> null (empty query guard)', async () => {
|
||||||
|
const deps = makeDeps({ getKey: () => 'cvkey' });
|
||||||
|
const ctx: LibraryNoteCtx = { frontmatter: {}, body: '', filename: '.md' };
|
||||||
|
const result = await comicSpec.resolve(ctx, deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comicSpec.sync — no id / no key', () => {
|
||||||
|
test('no comicvine_id -> null (needs resolve first)', async () => {
|
||||||
|
const deps = makeDeps({ getKey: () => 'cvkey' });
|
||||||
|
const result = await comicSpec.sync(ctxFor({}), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comicvine_id set, no key -> log, return null', async () => {
|
||||||
|
const deps = makeDeps({ getKey: () => '' });
|
||||||
|
const result = await comicSpec.sync(ctxFor({ comicvine_id: '195824' }), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(deps.logCalls.some(m => m.toLowerCase().includes('key'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comicSpec.sync — comicvine enrich', () => {
|
||||||
|
test('fetch throws -> log, return null (no throw)', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async () => {
|
||||||
|
throw new Error('network down');
|
||||||
|
},
|
||||||
|
getKey: () => 'cvkey',
|
||||||
|
});
|
||||||
|
const result = await comicSpec.sync(ctxFor({ comicvine_id: '195824' }), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(deps.logCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty/malformed results -> identity-guard: log, return null, no write', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => ({ results: {} }), getKey: () => 'cvkey' });
|
||||||
|
const result = await comicSpec.sync(ctxFor({ comicvine_id: '195824' }), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(deps.logCalls.some(m => m.includes('195824'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('response id mismatch -> identity-guard: log, return null, no write', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => ({ results: { ...CV_RESULT, id: 999999 } }), getKey: () => 'cvkey' });
|
||||||
|
const result = await comicSpec.sync(ctxFor({ comicvine_id: '195824' }), deps);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('successful enrich, no flip -> canonical fm rendered', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => comicvineFixture, getKey: () => 'cvkey' });
|
||||||
|
const fm = { comicvine_id: '195824', read_status: 'Reading', latest_issue: '10' };
|
||||||
|
const result = await comicSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('type: comic_item');
|
||||||
|
expect(result!.content).toContain('comicvine_id: 195824');
|
||||||
|
expect(result!.content).toContain('publisher: DC Comics');
|
||||||
|
expect(result!.content).toContain('issues: 10');
|
||||||
|
expect(deps.notifyCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetches from GET /volume/4050-{id}/ with api_key + format=json', async () => {
|
||||||
|
let calledUrl = '';
|
||||||
|
const deps = makeDeps({
|
||||||
|
http: async (url: string) => {
|
||||||
|
calledUrl = url;
|
||||||
|
return comicvineFixture;
|
||||||
|
},
|
||||||
|
getKey: () => 'cvkey',
|
||||||
|
});
|
||||||
|
await comicSpec.sync(ctxFor({ comicvine_id: '195824' }), deps);
|
||||||
|
expect(calledUrl).toContain('/volume/4050-195824/');
|
||||||
|
expect(calledUrl).toContain('api_key=cvkey');
|
||||||
|
expect(calledUrl).toContain('format=json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('comicSpec.sync — issue flip', () => {
|
||||||
|
test('read_status Read + numeric last_issue > stored latest_issue -> Unread, flipped, notify', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => comicvineFixture, getKey: () => 'cvkey' });
|
||||||
|
const fm = { comicvine_id: '195824', read_status: 'Read', latest_issue: '9' };
|
||||||
|
const result = await comicSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(true);
|
||||||
|
expect(result!.content).toContain('read_status: Unread');
|
||||||
|
expect(result!.content).toContain('latest_issue: 10');
|
||||||
|
expect(deps.notifyCalls).toEqual(['«Absolute Batman» issue 10 out']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decimal issue_number ("10.5") -> parseFloat compare, flip', async () => {
|
||||||
|
const cv = { results: { ...CV_RESULT, last_issue: { issue_number: '10.5', name: 'Annual' } } };
|
||||||
|
const deps = makeDeps({ http: async () => cv, getKey: () => 'cvkey' });
|
||||||
|
const fm = { comicvine_id: '195824', read_status: 'Read', latest_issue: '10' };
|
||||||
|
const result = await comicSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(true);
|
||||||
|
expect(result!.content).toContain('latest_issue: 10.5');
|
||||||
|
expect(deps.notifyCalls).toEqual(['«Absolute Batman» issue 10.5 out']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new issue <= stored -> no update, no flip', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => comicvineFixture, getKey: () => 'cvkey' });
|
||||||
|
const fm = { comicvine_id: '195824', read_status: 'Read', latest_issue: '10' };
|
||||||
|
const result = await comicSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('latest_issue: 10');
|
||||||
|
expect(deps.notifyCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-numeric issue_number -> skip flip, keep prev latest_issue', async () => {
|
||||||
|
const cv = { results: { ...CV_RESULT, last_issue: { issue_number: 'Annual', name: 'Special' } } };
|
||||||
|
const deps = makeDeps({ http: async () => cv, getKey: () => 'cvkey' });
|
||||||
|
const fm = { comicvine_id: '195824', read_status: 'Read', latest_issue: '9' };
|
||||||
|
const result = await comicSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('latest_issue: 9');
|
||||||
|
expect(deps.notifyCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing last_issue entirely -> skip flip, keep prev latest_issue', async () => {
|
||||||
|
const cv = { results: { ...CV_RESULT, last_issue: undefined } };
|
||||||
|
const deps = makeDeps({ http: async () => cv, getKey: () => 'cvkey' });
|
||||||
|
const fm = { comicvine_id: '195824', read_status: 'Read', latest_issue: '9' };
|
||||||
|
const result = await comicSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('latest_issue: 9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read_status Reading -> latest_issue updates but never flips', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => comicvineFixture, getKey: () => 'cvkey' });
|
||||||
|
const fm = { comicvine_id: '195824', read_status: 'Reading', latest_issue: '9' };
|
||||||
|
const result = await comicSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('read_status: Reading');
|
||||||
|
expect(result!.content).toContain('latest_issue: 10');
|
||||||
|
expect(deps.notifyCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read_status Unread -> never flipped', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => comicvineFixture, getKey: () => 'cvkey' });
|
||||||
|
const fm = { comicvine_id: '195824', read_status: 'Unread', latest_issue: '9' };
|
||||||
|
const result = await comicSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('read_status: Unread');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no prev latest_issue -> first-pass update, no flip unless Read', async () => {
|
||||||
|
const deps = makeDeps({ http: async () => comicvineFixture, getKey: () => 'cvkey' });
|
||||||
|
const fm = { comicvine_id: '195824', read_status: 'Unread' };
|
||||||
|
const result = await comicSpec.sync(ctxFor(fm), deps);
|
||||||
|
expect(result!.flipped).toBe(false);
|
||||||
|
expect(result!.content).toContain('latest_issue: 10');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue