diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts new file mode 100644 index 0000000..073cf97 --- /dev/null +++ b/packages/obsidian/src/library/manga.ts @@ -0,0 +1,288 @@ +import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import { parseFeed, latestChapter } from 'packages/obsidian/src/library/rss'; +import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse'; +import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; + +const JIKAN_BASE = 'https://api.jikan.moe/v4'; +const MANGADEX_BASE = 'https://api.mangadex.org'; + +export interface MangaRecord { + title: string; + engName: string; + readStatus: string; + rating: string; + ratingStars: string; + lastReadChapter: string; + latestChapter: number | null; + lastChapterDate: string; + chapters: number | null; + volumes: number | null; + status: string; + authors: string[]; + genre: string[]; + score: number | null; + publishedFrom: string | null; + publishedTo: string | null; + malId: string; + mangadexId: string; + rss: string; + poster: string | null; + url: string; + synopsis: string; +} + +interface ChapterUpdate { + chapter: number | null; + date: 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 { + 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): { 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: '' }; +} + +/** + * Pure mapper: Jikan `/manga/{id}/full` payload (the unwrapped `data` object) + prev + * frontmatter -> canonical MangaRecord. User-managed fields (read_status, rating, + * rating_stars, last_read_chapter, rss, mangadex_id, latest_chapter, last_chapter_date) + * are preserved from prev (with legacy-skeleton fallback); everything else is freshly + * derived from the Jikan response on every call. + */ +export function buildManga(jikan: any, prev: Record): MangaRecord { + const title: string = jikan.title ?? ''; + const titleEnglish: string = jikan.title_english ?? ''; + const engName = titleEnglish && titleEnglish !== title ? titleEnglish : ''; + + const { rating, ratingStars } = deriveRating(prev); + const readStatus = deriveReadStatus(prev); + const lastReadChapter = stripQuotes(prev['last_read_chapter']); + const rss = stripQuotes(prev['rss']); + const mangadexId = stripQuotes(prev['mangadex_id']); + + const score = typeof jikan.score === 'number' ? Math.round(jikan.score * 10) / 10 : null; + + return { + title, + engName, + readStatus, + rating, + ratingStars, + lastReadChapter, + latestChapter: parseNumOrNull(prev['latest_chapter']), + lastChapterDate: stripQuotes(prev['last_chapter_date']), + chapters: jikan.chapters ?? null, + volumes: jikan.volumes ?? null, + status: jikan.status ?? '', + authors: (jikan.authors ?? []).map((a: any) => a.name).filter(Boolean), + genre: (jikan.genres ?? []).map((g: any) => g.name).filter(Boolean), + score, + publishedFrom: jikan.published?.from ? String(jikan.published.from).slice(0, 10) : null, + publishedTo: jikan.published?.to ? String(jikan.published.to).slice(0, 10) : null, + malId: jikan.mal_id != null ? String(jikan.mal_id) : '', + mangadexId, + rss, + poster: jikan.images?.jpg?.large_image_url ?? null, + url: jikan.url ?? '', + synopsis: jikan.synopsis ?? '', + }; +} + +export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string { + const fm = [ + '---', + 'type: manga_item', + `title: ${yamlScalar(r.title)}`, + `eng_name: ${yamlScalar(r.engName)}`, + `read_status: ${r.readStatus}`, + `rating: ${r.rating}`, + `rating_stars: ${r.ratingStars}`, + `last_read_chapter: ${r.lastReadChapter}`, + `latest_chapter: ${r.latestChapter ?? 'null'}`, + `last_chapter_date: ${r.lastChapterDate ? r.lastChapterDate : 'null'}`, + `chapters: ${r.chapters ?? 'null'}`, + `volumes: ${r.volumes ?? 'null'}`, + `status: ${r.status}`, + `authors: ${yamlList(r.authors)}`, + `genre: ${yamlList(r.genre)}`, + `score: ${r.score ?? 'null'}`, + `published_from: ${r.publishedFrom ? r.publishedFrom : 'null'}`, + `published_to: ${r.publishedTo ? r.publishedTo : 'null'}`, + `mal_id: ${r.malId}`, + `mangadex_id: ${r.mangadexId}`, + `rss: ${quotedOrNull(r.rss)}`, + `poster: ${quotedOrNull(r.poster)}`, + `url: ${quotedOrNull(r.url)}`, + 'tags: [mangas, manga]', + '---', + ]; + + const b: string[] = ['', `# ${r.title}`]; + if (r.engName) b.push(`*${r.engName}*`); + b.push(''); + if (r.poster) b.push(`![poster|200](${r.poster})`, ''); + const meta = ['**Manga**', ...[r.status, r.score !== null ? String(r.score) : ''].filter(x => x)]; + b.push(meta.join(' · '), ''); + b.push(`**Read Status:** ${r.readStatus}`); + if (r.lastReadChapter) { + const denom = r.latestChapter ?? r.chapters ?? '?'; + b.push(`**Progress:** ch. ${r.lastReadChapter} / ${denom}`); + } + b.push(''); + if (r.synopsis) b.push('## Synopsis', r.synopsis, ''); + if (r.authors.length) b.push(`**Authors:** ${r.authors.join(', ')}`, ''); + const links: string[] = []; + if (r.url) links.push(`- [MAL page](${r.url})`); + if (r.rss) links.push(`- [RSS feed](${r.rss})`); + 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'); +} + +/** Chapter source cascade: rss (if set) -> mangadex_id (if set) -> null (leave unchanged). */ +async function resolveChapterUpdate(fm: Record, deps: SpecDeps): Promise { + const rss = stripQuotes(fm['rss']); + if (rss) { + try { + const xml = await deps.httpText(rss, {}); + const latest = latestChapter(parseFeed(xml)); + if (latest) return { chapter: latest.chapter, date: latest.date }; + } catch (e) { + deps.log(`manga rss fetch failed (${rss}): ${String(e)}`); + } + } + + const mangadexId = stripQuotes(fm['mangadex_id']); + if (mangadexId) { + try { + const url = `${MANGADEX_BASE}/manga/${mangadexId}/feed?order[readableAt]=desc&limit=1&translatedLanguage[]=en`; + const json = await deps.http(url, {}); + const attrs = json?.data?.[0]?.attributes; + if (attrs) { + const chapter = attrs.chapter ? parseFloat(attrs.chapter) : null; + const date = typeof attrs.readableAt === 'string' ? attrs.readableAt.slice(0, 10) : ''; + return { chapter: Number.isFinite(chapter as number) ? chapter : null, date }; + } + } catch (e) { + deps.log(`manga mangadex fetch failed (${mangadexId}): ${String(e)}`); + } + } + + return null; +} + +function resultTitles(r: any): string[] { + const titles = [r.title, r.title_english, ...(r.titles ?? []).map((t: any) => t.title)]; + return titles.filter(Boolean).map((t: string) => String(t).toLowerCase()); +} + +async function resolveMalId(query: string, deps: SpecDeps): Promise { + const qs = new URLSearchParams({ q: query, limit: '10' }); + const res = await deps.http(`${JIKAN_BASE}/manga?${qs.toString()}`, {}); + const results: any[] = res?.data ?? []; + if (results.length === 0) return null; + const q = query.toLowerCase(); + const exacts = results.filter(r => resultTitles(r).includes(q)); + const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null; + return pick ? String(pick.mal_id) : null; +} + +export const mangaSpec: MediaTypeSpec = { + typeName: 'manga', + itemType: 'manga_item', + folderSettingKey: 'libraryMangaFolder', + enabledSettingKey: 'libraryMangaEnabled', + throttleMs: 350, + + hasId(fm: Record): boolean { + return !!stripQuotes(fm['mal_id']); + }, + + isActive(fm: Record): boolean { + const malId = stripQuotes(fm['mal_id']); + const status = stripQuotes(fm['status']); + if (!malId || !status) return true; // never enriched -> needs first pass + if (status === 'Publishing' || status === 'On Hiatus') return true; + if (stripQuotes(fm['read_status']) === 'Reading') return true; + if (stripQuotes(fm['rss'])) return true; + return false; // Finished + Read/Unread/Dropped -> static + }, + + async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { + const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); + if (!query) return null; + try { + const malId = await resolveMalId(query, deps); + return malId ? { mal_id: malId } : null; + } catch (e) { + deps.log(`manga 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 malId = stripQuotes(fm['mal_id']); + if (!malId) return null; // needs resolve() first + + let jikanData: any; + try { + const res = await deps.http(`${JIKAN_BASE}/manga/${malId}/full`, {}); + jikanData = res?.data; + } catch (e) { + deps.log(`manga jikan fetch failed (mal_id ${malId}): ${String(e)}`); + return null; + } + if (!jikanData) return null; + + const record = buildManga(jikanData, fm); + const prevStatus = stripQuotes(fm['status']); + const prevLatestChapter = parseNumOrNull(fm['latest_chapter']); + const prevLastChapterDate = stripQuotes(fm['last_chapter_date']); + + let flipped = false; + const newInfo = await resolveChapterUpdate(fm, deps); + if (newInfo) { + const isUpdate = newInfo.chapter !== null ? prevLatestChapter === null || newInfo.chapter > prevLatestChapter : !!newInfo.date && newInfo.date > prevLastChapterDate; + if (isUpdate) { + record.latestChapter = newInfo.chapter; + if (newInfo.date) record.lastChapterDate = newInfo.date; + if (record.readStatus === 'Read') { + record.readStatus = 'Unread'; + flipped = true; + deps.notify(`«${record.title}» ch. ${newInfo.chapter ?? '?'} out`); + } + } + } + + // finish-flip: series completed while user had marked it Read + if (prevStatus === 'Publishing' && record.status === 'Finished' && record.readStatus === 'Read') { + record.readStatus = 'Unread'; + flipped = true; + } + + const content = renderManga(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); + return { content, flipped }; + }, +}; diff --git a/tests/fixtures/canonical-manga.md b/tests/fixtures/canonical-manga.md new file mode 100644 index 0000000..1e4a9cd --- /dev/null +++ b/tests/fixtures/canonical-manga.md @@ -0,0 +1,46 @@ +--- +type: manga_item +title: Chainsaw Man +eng_name: +read_status: Reading +rating: 4 +rating_stars: ⭐️⭐️⭐️⭐️ +last_read_chapter: 210 +latest_chapter: 213 +last_chapter_date: 2026-07-16 +chapters: null +volumes: null +status: Publishing +authors: ["Fujimoto, Tatsuki"] +genre: [Action, Horror, Sports] +score: 8.7 +published_from: 2018-12-03 +published_to: null +mal_id: 116778 +mangadex_id: abc-123 +rss: "https://example.com/csm-feed.xml" +poster: "https://cdn.myanimelist.net/images/manga/3/216464l.jpg" +url: "https://myanimelist.net/manga/116778/Chainsaw_Man" +tags: [mangas, manga] +--- + +# Chainsaw Man + +![poster|200](https://cdn.myanimelist.net/images/manga/3/216464l.jpg) + +**Manga** · Publishing · 8.7 + +**Read Status:** Reading +**Progress:** ch. 210 / 213 + +## Synopsis +Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him. + +**Authors:** Fujimoto, Tatsuki + +## Links +- [MAL page](https://myanimelist.net/manga/116778/Chainsaw_Man) +- [RSS feed](https://example.com/csm-feed.xml) + +## My Notes + diff --git a/tests/fixtures/jikan-manga-csm.json b/tests/fixtures/jikan-manga-csm.json new file mode 100644 index 0000000..14c5d47 --- /dev/null +++ b/tests/fixtures/jikan-manga-csm.json @@ -0,0 +1,40 @@ +{ + "data": { + "mal_id": 116778, + "url": "https://myanimelist.net/manga/116778/Chainsaw_Man", + "images": { + "jpg": { + "image_url": "https://cdn.myanimelist.net/images/manga/3/216464.jpg", + "large_image_url": "https://cdn.myanimelist.net/images/manga/3/216464l.jpg" + } + }, + "title": "Chainsaw Man", + "title_english": "Chainsaw Man", + "title_japanese": "チェンソーマン", + "titles": [ + { "type": "Default", "title": "Chainsaw Man" }, + { "type": "Japanese", "title": "チェンソーマン" }, + { "type": "English", "title": "Chainsaw Man" } + ], + "type": "Manga", + "chapters": null, + "volumes": null, + "status": "Publishing", + "publishing": true, + "published": { + "from": "2018-12-03T00:00:00+00:00", + "to": null + }, + "score": 8.73, + "scored_by": 42000, + "rank": 12, + "popularity": 3, + "synopsis": "Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him. Now, he'll hunt down devils with his devil-dog powers and try to survive in a world that sees him as a weapon.", + "authors": [{ "mal_id": 1, "type": "people", "name": "Fujimoto, Tatsuki" }], + "genres": [ + { "mal_id": 1, "type": "manga", "name": "Action" }, + { "mal_id": 8, "type": "manga", "name": "Horror" }, + { "mal_id": 30, "type": "manga", "name": "Sports" } + ] + } +} diff --git a/tests/fixtures/mangadex-feed.json b/tests/fixtures/mangadex-feed.json new file mode 100644 index 0000000..3c2373e --- /dev/null +++ b/tests/fixtures/mangadex-feed.json @@ -0,0 +1,20 @@ +{ + "result": "ok", + "response": "collection", + "data": [ + { + "id": "b1a2c3d4-0000-1111-2222-333344445555", + "type": "chapter", + "attributes": { + "chapter": "214", + "title": "", + "translatedLanguage": "en", + "publishAt": "2026-07-30T11:00:00+00:00", + "readableAt": "2026-07-30T12:00:00+00:00" + } + } + ], + "limit": 1, + "offset": 0, + "total": 214 +} diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts new file mode 100644 index 0000000..08c3b6e --- /dev/null +++ b/tests/library-manga.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildManga, renderManga, mangaSpec, type MangaRecord } from 'packages/obsidian/src/library/manga'; +import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; +import jikanFixture from 'tests/fixtures/jikan-manga-csm.json'; +import mangadexFixture from 'tests/fixtures/mangadex-feed.json'; + +const JIKAN_DATA = jikanFixture.data; + +const EMPTY_PREV: Record = {}; + +function makeDeps(overrides: Partial = {}): 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, + }; +} + +const RSS_214 = ` + +Chainsaw Man Chapter 214Thu, 30 Jul 2026 12:00:00 GMT214 +Chainsaw Man Chapter 213Thu, 23 Jul 2026 12:00:00 GMT213 +`; + +const RSS_NOISE_PLUS_214 = ` + +Chainsaw Man - #1 ranked this week!Thu, 30 Jul 2026 13:00:00 GMTnoise +Chainsaw Man Chapter 214Thu, 30 Jul 2026 12:00:00 GMT214 +`; + +const RSS_NUMBERLESS = ` + +Chainsaw Man - Extra AnnouncementThu, 30 Jul 2026 12:00:00 GMTa +`; + +describe('buildManga field mapping', () => { + const r = buildManga(JIKAN_DATA, EMPTY_PREV); + test('core fields', () => { + expect(r.title).toBe('Chainsaw Man'); + expect(r.malId).toBe('116778'); + expect(r.status).toBe('Publishing'); + expect(r.chapters).toBeNull(); + expect(r.volumes).toBeNull(); + expect(r.authors).toEqual(['Fujimoto, Tatsuki']); + expect(r.genre).toEqual(['Action', 'Horror', 'Sports']); + expect(r.publishedFrom).toBe('2018-12-03'); + expect(r.publishedTo).toBeNull(); + expect(r.poster).toBe('https://cdn.myanimelist.net/images/manga/3/216464l.jpg'); + expect(r.url).toBe('https://myanimelist.net/manga/116778/Chainsaw_Man'); + }); + test('score rounds to 1dp (8.73 -> 8.7)', () => { + expect(r.score).toBe(8.7); + }); + test('7.854 -> 7.9', () => { + expect(buildManga({ ...JIKAN_DATA, score: 7.854 }, EMPTY_PREV).score).toBe(7.9); + }); + test('null score -> null', () => { + expect(buildManga({ ...JIKAN_DATA, score: null }, EMPTY_PREV).score).toBeNull(); + }); + test('eng_name empty when title_english === title', () => { + expect(r.engName).toBe(''); + }); + test('eng_name set when title_english differs from title', () => { + const jp = { ...JIKAN_DATA, title: 'Chainsaw Man', title_english: 'Chainsaw Man EN Alt' }; + expect(buildManga(jp, EMPTY_PREV).engName).toBe('Chainsaw Man EN Alt'); + }); + test('status passthrough: Finished', () => { + expect(buildManga({ ...JIKAN_DATA, status: 'Finished' }, EMPTY_PREV).status).toBe('Finished'); + }); + test('status passthrough: On Hiatus', () => { + expect(buildManga({ ...JIKAN_DATA, status: 'On Hiatus' }, EMPTY_PREV).status).toBe('On Hiatus'); + }); +}); + +describe('buildManga user-field preservation', () => { + test('read_status defaults to Unread', () => { + expect(buildManga(JIKAN_DATA, EMPTY_PREV).readStatus).toBe('Unread'); + }); + test('read_status carried from prev', () => { + const prev = { read_status: 'Reading' }; + expect(buildManga(JIKAN_DATA, prev).readStatus).toBe('Reading'); + }); + test('rating/rating_stars carried from prev', () => { + const prev = { rating: '4', rating_stars: '⭐️⭐️⭐️⭐️' }; + const r = buildManga(JIKAN_DATA, prev); + expect(r.rating).toBe('4'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + }); + test('last_read_chapter carried from prev', () => { + expect(buildManga(JIKAN_DATA, { last_read_chapter: '150' }).lastReadChapter).toBe('150'); + }); + test('rss carried from prev', () => { + expect(buildManga(JIKAN_DATA, { rss: 'https://x.y/feed.xml' }).rss).toBe('https://x.y/feed.xml'); + }); + test('mangadex_id kept even when unrelated to jikan resolve', () => { + expect(buildManga(JIKAN_DATA, { mangadex_id: 'abc-123' }).mangadexId).toBe('abc-123'); + }); + test('latest_chapter/last_chapter_date preserved verbatim (not touched by buildManga)', () => { + const prev = { latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const r = buildManga(JIKAN_DATA, prev); + expect(r.latestChapter).toBe(213); + expect(r.lastChapterDate).toBe('2026-07-16'); + }); +}); + +describe('buildManga skeleton conversion', () => { + test('read: true -> read_status Read', () => { + const prev = { read: 'true', personalRating: '' }; + expect(buildManga(JIKAN_DATA, prev).readStatus).toBe('Read'); + }); + test('read: false -> read_status Unread', () => { + const prev = { read: 'false', personalRating: '' }; + expect(buildManga(JIKAN_DATA, prev).readStatus).toBe('Unread'); + }); + test('personalRating 3 -> rating 3 + 3 stars', () => { + const prev = { read: 'false', personalRating: '3' }; + const r = buildManga(JIKAN_DATA, prev); + expect(r.rating).toBe('3'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️'); + }); + test('empty personalRating -> rating 0, no stars', () => { + const prev = { read: 'false', personalRating: '' }; + const r = buildManga(JIKAN_DATA, prev); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + }); +}); + +describe('renderManga golden', () => { + const RECORD: MangaRecord = { + title: 'Chainsaw Man', + engName: '', + readStatus: 'Reading', + rating: '4', + ratingStars: '⭐️⭐️⭐️⭐️', + lastReadChapter: '210', + latestChapter: 213, + lastChapterDate: '2026-07-16', + chapters: null, + volumes: null, + status: 'Publishing', + authors: ['Fujimoto, Tatsuki'], + genre: ['Action', 'Horror', 'Sports'], + score: 8.7, + publishedFrom: '2018-12-03', + publishedTo: null, + malId: '116778', + mangadexId: 'abc-123', + rss: 'https://example.com/csm-feed.xml', + poster: 'https://cdn.myanimelist.net/images/manga/3/216464l.jpg', + url: 'https://myanimelist.net/manga/116778/Chainsaw_Man', + synopsis: 'Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him.', + }; + + test('matches canonical manga fixture byte-for-byte', () => { + const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-manga.md'), 'utf-8'); + expect(renderManga(RECORD, '')).toBe(expected); + }); + + test('custom section (Collection) placed after Links, before My Notes', () => { + const out = renderManga(RECORD, '', [{ heading: 'Collection', content: 'Part of [[Mangas]]' }]); + 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 [[Mangas]]\n'); + }); + + test('My Notes content preserved', () => { + const out = renderManga(RECORD, 'currently reading'); + expect(out).toContain('## My Notes\n\ncurrently reading'); + }); + + test('no last_read_chapter -> Progress line omitted', () => { + const r = { ...RECORD, lastReadChapter: '' }; + expect(renderManga(r, '')).not.toContain('**Progress:**'); + }); + + test('Progress denominator falls back to chapters when latest_chapter null', () => { + const r = { ...RECORD, latestChapter: null, chapters: 150 }; + expect(renderManga(r, '')).toContain('**Progress:** ch. 210 / 150'); + }); + + test('Progress denominator falls back to ? when both null', () => { + const r = { ...RECORD, latestChapter: null, chapters: null }; + expect(renderManga(r, '')).toContain('**Progress:** ch. 210 / ?'); + }); +}); + +describe('mangaSpec.hasId', () => { + test('mal_id set -> true', () => expect(mangaSpec.hasId({ mal_id: '116778' })).toBe(true)); + test('mal_id empty -> false', () => expect(mangaSpec.hasId({ mal_id: '' })).toBe(false)); + test('mal_id missing -> false', () => expect(mangaSpec.hasId({})).toBe(false)); +}); + +describe('mangaSpec.isActive', () => { + test('never enriched (no mal_id/status) -> active', () => { + expect(mangaSpec.isActive({})).toBe(true); + }); + test('Publishing -> active', () => { + expect(mangaSpec.isActive({ mal_id: '1', status: 'Publishing', read_status: 'Unread' })).toBe(true); + }); + test('On Hiatus -> active', () => { + expect(mangaSpec.isActive({ mal_id: '1', status: 'On Hiatus', read_status: 'Unread' })).toBe(true); + }); + test('Finished + read_status Reading -> active', () => { + expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Reading' })).toBe(true); + }); + test('Finished + rss set -> active', () => { + expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Unread', rss: 'https://x.y/f.xml' })).toBe(true); + }); + test('Finished + Read -> static', () => { + expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Read' })).toBe(false); + }); + test('Finished + Unread -> static', () => { + expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Unread' })).toBe(false); + }); +}); + +function ctxFor(fm: Record, body = '## My Notes\n\n'): LibraryNoteCtx { + return { frontmatter: fm, body, filename: 'Chainsaw Man.md' }; +} + +describe('mangaSpec.sync — jikan enrich', () => { + test('no mal_id -> null (needs resolve first)', async () => { + const deps = makeDeps(); + const result = await mangaSpec.sync(ctxFor({}), deps); + expect(result).toBeNull(); + }); + + test('jikan fetch failure -> log, return null (no throw)', async () => { + const deps = makeDeps({ + http: async () => { + throw new Error('network down'); + }, + }); + const result = await mangaSpec.sync(ctxFor({ mal_id: '116778' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); + + test('successful enrich with no rss/mangadex_id -> latest_chapter unchanged, no flip', async () => { + const deps = makeDeps({ http: async () => jikanFixture }); + const fm = { mal_id: '116778', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('latest_chapter: 213'); + expect(result!.content).toContain('read_status: Read'); + expect(deps.notifyCalls).toEqual([]); + }); +}); + +describe('mangaSpec.sync — chapter source priority', () => { + test('rss set -> httpText fetched, latestChapter() used, new > stored -> update + flip + notify', async () => { + let httpTextCalledWith = ''; + const deps = makeDeps({ + http: async () => jikanFixture, + httpText: async url => { + httpTextCalledWith = url; + return RSS_214; + }, + }); + const fm = { mal_id: '116778', rss: 'https://example.com/csm-feed.xml', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(httpTextCalledWith).toBe('https://example.com/csm-feed.xml'); + expect(result!.flipped).toBe(true); + expect(result!.content).toContain('latest_chapter: 214'); + expect(result!.content).toContain('read_status: Unread'); + expect(deps.notifyCalls).toEqual(['«Chainsaw Man» ch. 214 out']); + }); + + test('rss not set, mangadex_id set -> mangadex feed endpoint used', async () => { + let httpCalls: string[] = []; + const deps = makeDeps({ + http: async url => { + httpCalls.push(url); + return url.includes('mangadex.org') ? mangadexFixture : jikanFixture; + }, + }); + const fm = { mal_id: '116778', mangadex_id: 'abc-123', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(httpCalls.some(u => u.includes('/manga/abc-123/feed'))).toBe(true); + expect(result!.flipped).toBe(true); + expect(result!.content).toContain('latest_chapter: 214'); + }); + + test('neither rss nor mangadex_id -> latest_chapter unchanged, no flip', async () => { + const deps = makeDeps({ http: async () => jikanFixture }); + const fm = { mal_id: '116778', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('latest_chapter: 213'); + }); + + test('new chapter <= stored -> no update, no flip', async () => { + const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_214 }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '214', last_chapter_date: '2026-07-30' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('latest_chapter: 214'); + expect(deps.notifyCalls).toEqual([]); + }); + + test('numberless feed result: new date > stored last_chapter_date -> update + flip, chapter stays null', async () => { + const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_NUMBERLESS }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '', last_chapter_date: '2026-07-01' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(true); + expect(result!.content).toContain('latest_chapter: null'); + expect(result!.content).toContain('last_chapter_date: 2026-07-30'); + }); + + test('numberless feed result: new date <= stored -> no update', async () => { + const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_NUMBERLESS }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '', last_chapter_date: '2026-08-15' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('last_chapter_date: 2026-08-15'); + }); + + test('read_status Reading -> chapter update happens but never flips', async () => { + const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_214 }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Reading', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('read_status: Reading'); + expect(result!.content).toContain('latest_chapter: 214'); + expect(deps.notifyCalls).toEqual([]); + }); + + test('read_status Unread -> never flipped', async () => { + const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_214 }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Unread', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('read_status: Unread'); + }); + + test('read_status Dropped -> never flipped', async () => { + const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_214 }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Dropped', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('read_status: Dropped'); + }); + + test('rss fetch failure -> log, proceed with mangadex fallback (no throw)', async () => { + const deps = makeDeps({ + http: async url => (url.includes('mangadex.org') ? mangadexFixture : jikanFixture), + httpText: async () => { + throw new Error('rss unreachable'); + }, + }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', mangadex_id: 'abc-123', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect(deps.logCalls.some(m => m.includes('rss'))).toBe(true); + expect(result!.flipped).toBe(true); + expect(result!.content).toContain('latest_chapter: 214'); + }); + + test('rss fetch failure, no mangadex_id -> log, jikan enrich still completes, latest_chapter unchanged', async () => { + const deps = makeDeps({ + http: async () => jikanFixture, + httpText: async () => { + throw new Error('rss unreachable'); + }, + }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('latest_chapter: 213'); + expect(result!.content).toContain('mal_id: 116778'); + }); +}); + +describe('mangaSpec.sync — finish-flip', () => { + test('prev Publishing + new Finished + read_status Read -> Unread, flipped', async () => { + const deps = makeDeps({ http: async () => ({ data: { ...JIKAN_DATA, status: 'Finished' } }) }); + const fm = { mal_id: '116778', status: 'Publishing', read_status: 'Read' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(true); + expect(result!.content).toContain('read_status: Unread'); + expect(result!.content).toContain('status: Finished'); + }); + test('prev Publishing + new Finished + read_status Reading -> no flip', async () => { + const deps = makeDeps({ http: async () => ({ data: { ...JIKAN_DATA, status: 'Finished' } }) }); + const fm = { mal_id: '116778', status: 'Publishing', read_status: 'Reading' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('read_status: Reading'); + }); +}); + +describe('mangaSpec.sync — carry-forward: noise item does not win over real chapter', () => { + test('feed w/ "#1 ranked" noise + real "Chapter 214", stored 213 -> latest becomes 214 (not 1), flips once', async () => { + const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_NOISE_PLUS_214 }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(true); + expect(result!.content).toContain('latest_chapter: 214'); + expect(result!.content).not.toContain('latest_chapter: 1\n'); + expect(deps.notifyCalls).toEqual(['«Chainsaw Man» ch. 214 out']); + }); + + test('second run with same feed -> no flip, no diff', async () => { + const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_NOISE_PLUS_214 }); + const fm1 = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const first = await mangaSpec.sync(ctxFor(fm1), deps); + expect(first!.flipped).toBe(true); + + // re-parse first run's output frontmatter as prev state for second run + const fmMatch = /^---\n([\s\S]*?)\n---/.exec(first!.content)!; + const fm2: Record = {}; + for (const line of fmMatch[1].split('\n')) { + const m = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (m) fm2[m[1]] = m[2]; + } + + const second = await mangaSpec.sync(ctxFor(fm2), deps); + expect(second!.flipped).toBe(false); + expect(second!.content).toBe(first!.content); + expect(deps.notifyCalls.length).toBe(1); // only the first run notified + }); +}); + +describe('mangaSpec.resolve', () => { + test('unique exact title match -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + }); + test('no exact match, sole result -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ data: [{ mal_id: 999, title: 'Some Other Title', title_english: '' }] }), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '999' }); + }); + test('ambiguous (multiple results, no exact match) -> null', async () => { + const deps = makeDeps({ + http: async () => ({ + data: [ + { mal_id: 1, title: 'Foo' }, + { mal_id: 2, title: 'Bar' }, + ], + }), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toBeNull(); + }); + test('no results -> null', async () => { + const deps = makeDeps({ http: async () => ({ data: [] }) }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toBeNull(); + }); + test('http throws -> log, return null (no throw)', async () => { + const deps = makeDeps({ + http: async () => { + throw new Error('down'); + }, + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); +});