From ea5ddd94546f025974cf16f3a1f58942c99ce306 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 22:08:40 +0800 Subject: [PATCH] fix(library): seed-pass flip guard, manga null-sentinel, mangadex resolve - comic/manga sync(): a note's first-ever observed issue/chapter number (no prior latest_issue/latest_chapter) now seeds the baseline without flipping read_status or notifying. Previously `prev === null` counted as "newer", so a Read note with no stored baseline flipped to Unread on its very first real sync. Flips now require a prior value AND a strictly greater candidate AND read_status Read, mirroring the watchlist build.ts `prevLast &&` guard. - manga rss/last_chapter_date: quotedOrNull renders an unset value as the literal `null` token; on the next parse that string came back truthy, so an unset rss field was fetched as the URL "null" and isActive treated it as "has rss -> active" forever. Sentinel is now stripped back to '' everywhere it's read from frontmatter. - manga resolve(): best-effort MangaDex id lookup by title (unique-exact match against attributes.title + altTitles) runs alongside the existing MAL lookup when the note has no mangadex_id yet. Any failure (no match, ambiguous, network error) is logged and skipped, leaving the mal_id patch intact. --- packages/obsidian/src/library/comic.ts | 8 +- packages/obsidian/src/library/manga.ts | 69 +++++++++-- tests/library-comic.test.ts | 23 ++++ tests/library-manga.test.ts | 165 +++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 11 deletions(-) diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index 279cef6..f9e3933 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -222,8 +222,12 @@ export const comicSpec: MediaTypeSpec = { let flipped = false; if (Number.isFinite(candidate)) { - const isUpdate = prevLatestIssue === null || candidate > prevLatestIssue; - if (isUpdate) { + if (prevLatestIssue === null) { + // seed pass: first-ever observed issue number, nothing to compare against yet -- + // record it as the baseline, never flip/notify (mirrors watchlist build.ts's + // `prevLast &&` guard: there's no "new" issue relative to an unknown starting point) + record.latestIssue = candidate; + } else if (candidate > prevLatestIssue) { record.latestIssue = candidate; if (record.readStatus === 'Read') { record.readStatus = 'Unread'; diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index bdd722f..1ba7fbc 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -37,6 +37,13 @@ interface ChapterUpdate { date: string; } +/** `quotedOrNull` renders empty values as the literal `null` token -- strip that sentinel back + * to '' so a prior blank rss/last_chapter_date never round-trips as a truthy value (never fetched + * as a URL, never wins a string date comparison). */ +function stripNullSentinel(s: string): string { + return s === 'null' ? '' : s; +} + /** * Pure mapper: Jikan `/manga/{id}/full` payload (the unwrapped `data` object) + prev * frontmatter -> canonical MangaRecord. User-managed fields (read_status, rating, @@ -52,7 +59,7 @@ export function buildManga(jikan: any, prev: Record): MangaRecor const { rating, ratingStars } = deriveRating(prev); const readStatus = deriveReadStatus(prev); const lastReadChapter = stripQuotes(prev['last_read_chapter']); - const rss = stripQuotes(prev['rss']); + const rss = stripNullSentinel(stripQuotes(prev['rss'])); const mangadexId = stripQuotes(prev['mangadex_id']); const score = typeof jikan.score === 'number' ? Math.round(jikan.score * 10) / 10 : null; @@ -65,7 +72,7 @@ export function buildManga(jikan: any, prev: Record): MangaRecor ratingStars, lastReadChapter, latestChapter: parseNumOrNull(prev['latest_chapter']), - lastChapterDate: stripQuotes(prev['last_chapter_date']), + lastChapterDate: stripNullSentinel(stripQuotes(prev['last_chapter_date'])), chapters: jikan.chapters ?? null, volumes: jikan.volumes ?? null, status: jikan.status ?? '', @@ -139,7 +146,7 @@ export function renderManga(r: MangaRecord, myNotes: string, customSections: Cus /** 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']); + const rss = stripNullSentinel(stripQuotes(fm['rss'])); if (rss) { try { const xml = await deps.httpText(rss, {}); @@ -185,6 +192,34 @@ async function resolveMalId(query: string, deps: SpecDeps): Promisestring object; `attributes.altTitles` an array of the + * same shape -- collect every string value across both for the unique-exact title match. */ +function mangaDexTitles(r: any): string[] { + const titleObj: Record = r?.attributes?.title ?? {}; + const altTitleObjs: Record[] = r?.attributes?.altTitles ?? []; + const titles = [...Object.values(titleObj), ...altTitleObjs.flatMap(o => Object.values(o))]; + return titles.filter(Boolean).map((t: string) => String(t).toLowerCase()); +} + +/** Best-effort MangaDex id resolve, run only when the note has no mangadex_id yet. Failure of any + * kind (network error, no match, ambiguous match) is non-fatal to the overall resolve() -- it's + * logged and simply skipped, leaving the mal_id patch (if any) as the only result. */ +async function resolveMangadexId(query: string, deps: SpecDeps): Promise { + try { + const qs = new URLSearchParams({ title: query, limit: '10' }); + const res = await deps.http(`${MANGADEX_BASE}/manga?${qs.toString()}`, {}); + const results: any[] = res?.data ?? []; + if (results.length === 0) return null; + const q = query.toLowerCase(); + const exacts = results.filter(r => mangaDexTitles(r).includes(q)); + const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null; + return pick?.id ? String(pick.id) : null; + } catch (e) { + deps.log(`manga mangadex resolve failed for "${query}": ${String(e)}`); + return null; + } +} + export const mangaSpec: MediaTypeSpec = { typeName: 'manga', itemType: 'manga_item', @@ -202,20 +237,29 @@ export const mangaSpec: MediaTypeSpec = { 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; + if (stripNullSentinel(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; + let malId: string | null; try { - const malId = await resolveMalId(query, deps); - return malId ? { mal_id: malId } : null; + malId = await resolveMalId(query, deps); } catch (e) { deps.log(`manga resolve failed for "${query}": ${String(e)}`); return null; } + if (!malId) return null; + + const patch: Record = { mal_id: malId }; + // best-effort: only attempt when the note doesn't already carry a mangadex_id + if (!stripQuotes(ctx.frontmatter['mangadex_id'])) { + const mangadexId = await resolveMangadexId(query, deps); + if (mangadexId) patch.mangadex_id = mangadexId; + } + return patch; }, async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> { @@ -236,16 +280,23 @@ export const mangaSpec: MediaTypeSpec = { const record = buildManga(jikanData, fm); const prevStatus = stripQuotes(fm['status']); const prevLatestChapter = parseNumOrNull(fm['latest_chapter']); - const prevLastChapterDate = stripQuotes(fm['last_chapter_date']); + const prevLastChapterDate = stripNullSentinel(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; + const chapterMode = newInfo.chapter !== null; + const prevPresent = chapterMode ? prevLatestChapter !== null : !!prevLastChapterDate; + const isUpdate = chapterMode + ? prevLatestChapter === null || newInfo.chapter! > prevLatestChapter + : !prevLastChapterDate || (!!newInfo.date && newInfo.date > prevLastChapterDate); if (isUpdate) { record.latestChapter = newInfo.chapter; if (newInfo.date) record.lastChapterDate = newInfo.date; - if (record.readStatus === 'Read') { + // seed pass (no prior stored baseline) -- record it as the new baseline, never + // flip/notify: mirrors watchlist build.ts's `prevLast &&` guard, there's no "new" + // chapter relative to an unknown starting point + if (prevPresent && record.readStatus === 'Read') { record.readStatus = 'Unread'; flipped = true; deps.notify(`«${record.title}» ch. ${newInfo.chapter ?? '?'} out`); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index f0850a6..a0e4bd9 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -462,3 +462,26 @@ describe('comicSpec.sync — issue flip', () => { expect(result!.content).toContain('latest_issue: 10'); }); }); + +describe('comicSpec.sync — seed pass (I3): no stored latest_issue never flips even when Read', () => { + test('Read comic, no stored latest_issue, API issue 10 -> seeds latest_issue, read_status stays Read, no notify', async () => { + const deps = makeDeps({ http: async () => comicvineFixture, getKey: () => 'cvkey' }); + const fm = { comicvine_id: '195824', read_status: 'Read' }; + const result = await comicSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('latest_issue: 10'); + expect(result!.content).toContain('read_status: Read'); + expect(deps.notifyCalls).toEqual([]); + }); + + test('subsequent sync with a higher issue number -> flip + notify (baseline now present)', async () => { + const cvNext = { results: { ...CV_RESULT, last_issue: { issue_number: '11', name: 'Next Issue' } } }; + const deps = makeDeps({ http: async () => cvNext, 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: 11'); + expect(result!.content).toContain('read_status: Unread'); + expect(deps.notifyCalls).toEqual(['«Absolute Batman» issue 11 out']); + }); +}); diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index 29d4f06..166161d 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -46,6 +46,12 @@ const RSS_NUMBERLESS = ` Chainsaw Man - Extra AnnouncementThu, 30 Jul 2026 12:00:00 GMTa `; +const RSS_215 = ` + +Chainsaw Man Chapter 215Thu, 06 Aug 2026 12:00:00 GMT215 +Chainsaw Man Chapter 214Thu, 30 Jul 2026 12:00:00 GMT214 +`; + describe('buildManga field mapping', () => { const r = buildManga(JIKAN_DATA, EMPTY_PREV); test('core fields', () => { @@ -238,6 +244,9 @@ describe('mangaSpec.isActive', () => { test('Finished + Unread -> static', () => { expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Unread' })).toBe(false); }); + test('Finished + rss rendered as literal "null" sentinel -> not read as truthy, static (C2)', () => { + expect(mangaSpec.isActive({ mal_id: '1', status: 'Finished', read_status: 'Read', rss: 'null' })).toBe(false); + }); }); function ctxFor(fm: Record, body = '## My Notes\n\n'): LibraryNoteCtx { @@ -463,6 +472,94 @@ describe('mangaSpec.sync — carry-forward: noise item does not win over real ch }); }); +describe('mangaSpec.sync — rss/last_chapter_date null-sentinel guard (C2)', () => { + test('rss stored as literal "null" string (already-broken note) -> not fetched, treated as empty', async () => { + let httpTextCalledWith: string | null = null; + const deps = makeDeps({ + http: async () => jikanFixture, + httpText: async (url: string) => { + httpTextCalledWith = url; + return ''; + }, + }); + const fm = { mal_id: '116778', rss: 'null', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(httpTextCalledWith).toBeNull(); + expect(result!.content).toContain('rss: null'); + expect(result!.flipped).toBe(false); + }); + + test('render(parse(render)) idempotent w/ empty rss -- second sync byte-stable, never fetches "null"', async () => { + const httpTextCalls: string[] = []; + const deps = makeDeps({ + http: async () => jikanFixture, + httpText: async (url: string) => { + httpTextCalls.push(url); + return ''; + }, + }); + const fm1 = { mal_id: '116778', read_status: 'Read', latest_chapter: '213', last_chapter_date: '2026-07-16' }; + const first = await mangaSpec.sync(ctxFor(fm1), deps); + expect(first!.content).toContain('rss: null'); + + 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]; + } + expect(fm2['rss']).toBe('null'); // confirms the sentinel round-trips through parse as raw input + + const second = await mangaSpec.sync(ctxFor(fm2), deps); + expect(second!.content).toBe(first!.content); + expect(httpTextCalls).toEqual([]); // rss never truthy after sanitizing -> httpText never called, let alone with 'null' + }); + + test('last_chapter_date stored as literal "null" -- date-mode comparison not poisoned, treated as seed', 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: 'null' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); // sanitized to '' -> seed pass, never flips + expect(result!.content).toContain('last_chapter_date: 2026-07-30'); + expect(deps.notifyCalls).toEqual([]); + }); +}); + +describe('mangaSpec.sync — seed pass (I3): no stored baseline never flips even when Read', () => { + test('Read manga, no stored latest_chapter, rss reports chapter 214 -> seeds latest_chapter, read_status stays Read, no notify', 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' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('latest_chapter: 214'); + expect(result!.content).toContain('read_status: Read'); + expect(deps.notifyCalls).toEqual([]); + }); + + test('seed pass then a later sync with a higher chapter -> flip + notify only on the second sync', async () => { + const deps = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_214 }); + const fm1 = { mal_id: '116778', rss: 'https://x.y/f.xml', read_status: 'Read' }; + const first = await mangaSpec.sync(ctxFor(fm1), deps); + expect(first!.flipped).toBe(false); + expect(first!.content).toContain('latest_chapter: 214'); + expect(deps.notifyCalls).toEqual([]); + + 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 deps2 = makeDeps({ http: async () => jikanFixture, httpText: async () => RSS_215 }); + const second = await mangaSpec.sync(ctxFor(fm2), deps2); + expect(second!.flipped).toBe(true); + expect(second!.content).toContain('latest_chapter: 215'); + expect(second!.content).toContain('read_status: Unread'); + expect(deps2.notifyCalls).toEqual(['«Chainsaw Man» ch. 215 out']); + }); +}); + describe('mangaSpec.resolve', () => { test('unique exact title match -> accepted', async () => { const deps = makeDeps({ @@ -506,3 +603,71 @@ describe('mangaSpec.resolve', () => { expect(deps.logCalls.length).toBeGreaterThan(0); }); }); + +describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => { + const jikanMatch = { data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }; + + test('mal_id resolved, no mangadex_id in fm -> mangadex title search attempted, exact en-title match patches both ids', async () => { + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('mangadex.org')) return { data: [{ id: 'a1b2c3d4-uuid', attributes: { title: { en: 'Chainsaw Man' }, altTitles: [{ ja: 'チェンソーマン' }] } }] }; + return jikanMatch; + }, + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', mangadex_id: 'a1b2c3d4-uuid' }); + }); + + test('mangadex search no exact match, sole result -> accepted (unique-exact fallback rule)', async () => { + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('mangadex.org')) return { data: [{ id: 'uuid-solo', attributes: { title: { en: 'Chainsaw Man: The Movie' } } }] }; + return jikanMatch; + }, + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', mangadex_id: 'uuid-solo' }); + }); + + test('mangadex search ambiguous (multiple results, no exact match) -> mal_id patched only', async () => { + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('mangadex.org')) + return { + data: [ + { id: 'uuid-1', attributes: { title: { en: 'Something Else' } } }, + { id: 'uuid-2', attributes: { title: { en: 'Another Title' } } }, + ], + }; + return jikanMatch; + }, + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + }); + + test('mangadex search throws -> log, mal_id patched only (best-effort, no overall failure)', async () => { + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('mangadex.org')) throw new Error('mangadex down'); + return jikanMatch; + }, + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + expect(deps.logCalls.some(m => m.toLowerCase().includes('mangadex'))).toBe(true); + }); + + test('mangadex_id already present in fm -> mangadex search skipped entirely', async () => { + let mangadexCalled = false; + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('mangadex.org')) mangadexCalled = true; + return jikanMatch; + }, + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man', mangadex_id: 'existing-uuid' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + expect(mangadexCalled).toBe(false); + }); +});