diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 93cb79d..8e53743 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -1,6 +1,7 @@ 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'; +import { deriveReadStatus, deriveRating } from 'packages/obsidian/src/library/convert'; const OPENLIBRARY_BASE = 'https://openlibrary.org'; @@ -19,24 +20,6 @@ export interface BookRecord { url: string; } -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: '' }; -} - function deriveAuthors(doc: any, prev: Record): string[] { const fresh: string[] = Array.isArray(doc.author_name) ? doc.author_name.filter(Boolean) : []; if (fresh.length) return fresh; @@ -111,7 +94,9 @@ export function renderBook(r: BookRecord, myNotes: string, customSections: Custo if (r.poster) b.push(`![poster|200](${r.poster})`, ''); const meta = ['**Book**', ...[r.year !== null ? String(r.year) : '', r.pages !== null ? `${r.pages} p.` : ''].filter(x => x)]; b.push(meta.join(' · '), ''); - b.push(`**Read Status:** ${r.readStatus}`, ''); + b.push(`**Read Status:** ${r.readStatus}`); + if (r.rating !== '0' && r.rating !== '' && r.ratingStars) b.push(`**Rating:** ${r.ratingStars} (${r.rating}/5)`); + b.push(''); if (r.authors.length) b.push(`**Authors:** ${r.authors.join(', ')}`, ''); const links: string[] = []; if (r.url) links.push(`- [Open Library](${r.url})`); diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index d797978..6695792 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -1,6 +1,7 @@ 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'; +import { deriveReadStatus, deriveRating, parseNumOrNull } from 'packages/obsidian/src/library/convert'; const COMICVINE_BASE = 'https://comicvine.gamespot.com/api'; @@ -22,31 +23,6 @@ export interface ComicRecord { 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 { - 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: '' }; -} - /** * 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 @@ -143,6 +119,7 @@ export function renderComic(r: ComicRecord, myNotes: string, customSections: Cus const meta = ['**Comic**', ...[r.status, r.startYear].filter(x => x)]; b.push(meta.join(' · '), ''); b.push(`**Read Status:** ${r.readStatus}`); + if (r.rating !== '0' && r.rating !== '' && r.ratingStars) b.push(`**Rating:** ${r.ratingStars} (${r.rating}/5)`); if (r.lastReadIssue) { const denom = r.latestIssue ?? r.issues ?? '?'; b.push(`**Progress:** issue ${r.lastReadIssue} / ${denom}`); diff --git a/packages/obsidian/src/library/convert.ts b/packages/obsidian/src/library/convert.ts new file mode 100644 index 0000000..a87f4dc --- /dev/null +++ b/packages/obsidian/src/library/convert.ts @@ -0,0 +1,29 @@ +import { stripQuotes } from 'packages/obsidian/src/watchlist/parse'; + +/** Shared across manga/comic: parse a stored numeric frontmatter field, tolerating 'null'/empty -> null. */ +export 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; +} + +/** Shared across manga/book/comic: canonical read_status, with legacy boolean `read` skeleton fallback. */ +export 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'; +} + +/** Shared across manga/book/game/comic: canonical rating + stars, with legacy numeric `personalRating` skeleton fallback. */ +export 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: '' }; +} diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts index 7f6fd5a..f492b63 100644 --- a/packages/obsidian/src/library/game.ts +++ b/packages/obsidian/src/library/game.ts @@ -1,6 +1,7 @@ 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'; +import { deriveRating } from 'packages/obsidian/src/library/convert'; const STEAM_STORE_BASE = 'https://store.steampowered.com'; const RAWG_BASE = 'https://api.rawg.io/api'; @@ -47,17 +48,6 @@ export interface GameRecord { description: string; } -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: '' }; -} - function derivePlayStatus(prev: Record): string { const canonical = stripQuotes(prev['play_status']); if (canonical) return canonical; @@ -150,13 +140,19 @@ export function renderGame(r: GameRecord, myNotes: string, customSections: Custo if (r.poster) b.push(`![poster|200](${r.poster})`, ''); const meta = ['**Game**', ...[r.releaseDate, r.metacritic !== null ? `Metacritic ${r.metacritic}` : ''].filter(x => x)]; b.push(meta.join(' · '), ''); - b.push(`**Play Status:** ${r.playStatus}`, ''); + b.push(`**Play Status:** ${r.playStatus}`); + if (r.rating !== '0' && r.rating !== '' && r.ratingStars) b.push(`**Rating:** ${r.ratingStars} (${r.rating}/5)`); + b.push(''); if (r.description) b.push('## Synopsis', r.description, ''); const facts: string[] = []; if (r.developer.length) facts.push(`**Developer:** ${r.developer.join(', ')}`); if (r.publisher.length) facts.push(`**Publisher:** ${r.publisher.join(', ')}`); if (r.platforms.length) facts.push(`**Platforms:** ${r.platforms.join(', ')}`); if (facts.length) b.push(...facts, ''); + const links: string[] = []; + if (r.steamAppid) links.push(`- [Steam](${STEAM_STORE_BASE}/app/${r.steamAppid}/)`); + if (r.rawgId && r.url) links.push(`- [RAWG](${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(''); diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index 073cf97..bdd722f 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -2,6 +2,7 @@ import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/ 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'; +import { deriveReadStatus, deriveRating, parseNumOrNull } from 'packages/obsidian/src/library/convert'; const JIKAN_BASE = 'https://api.jikan.moe/v4'; const MANGADEX_BASE = 'https://api.mangadex.org'; @@ -36,31 +37,6 @@ interface ChapterUpdate { 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, @@ -143,6 +119,7 @@ export function renderManga(r: MangaRecord, myNotes: string, customSections: Cus 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.rating !== '0' && r.rating !== '' && r.ratingStars) b.push(`**Rating:** ${r.ratingStars} (${r.rating}/5)`); if (r.lastReadChapter) { const denom = r.latestChapter ?? r.chapters ?? '?'; b.push(`**Progress:** ch. ${r.lastReadChapter} / ${denom}`); @@ -280,6 +257,7 @@ export const mangaSpec: MediaTypeSpec = { if (prevStatus === 'Publishing' && record.status === 'Finished' && record.readStatus === 'Read') { record.readStatus = 'Unread'; flipped = true; + deps.notify(`«${record.title}» finished — final chapters out`); } const content = renderManga(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); diff --git a/tests/fixtures/canonical-book.md b/tests/fixtures/canonical-book.md index f2b1f5f..275d821 100644 --- a/tests/fixtures/canonical-book.md +++ b/tests/fixtures/canonical-book.md @@ -22,6 +22,7 @@ tags: [books, book] **Book** · 1949 · 328 p. **Read Status:** Read +**Rating:** ⭐️⭐️⭐️⭐️⭐️ (5/5) **Authors:** George Orwell diff --git a/tests/fixtures/canonical-comic.md b/tests/fixtures/canonical-comic.md index bf42c3c..6f01135 100644 --- a/tests/fixtures/canonical-comic.md +++ b/tests/fixtures/canonical-comic.md @@ -24,6 +24,7 @@ tags: [comics, comic] **Comic** · Ongoing · 2024 **Read Status:** Reading +**Rating:** ⭐️⭐️⭐️⭐️ (4/5) **Progress:** issue 8 / 10 ## Synopsis diff --git a/tests/fixtures/canonical-game.md b/tests/fixtures/canonical-game.md index 14470f3..3c419f1 100644 --- a/tests/fixtures/canonical-game.md +++ b/tests/fixtures/canonical-game.md @@ -24,6 +24,7 @@ tags: [games, game] **Game** · 2018-03-02 · Metacritic 79 **Play Status:** Played +**Rating:** ⭐️⭐️⭐️⭐️ (4/5) ## Synopsis From the creators of Human Resource Machine! Program a workforce of dumb humans to do your bidding in this fiendish puzzle game. @@ -32,5 +33,8 @@ From the creators of Human Resource Machine! Program a workforce of dumb humans **Publisher:** Tomorrow Corporation **Platforms:** PC +## Links +- [Steam](https://store.steampowered.com/app/792100/) + ## My Notes diff --git a/tests/fixtures/canonical-manga.md b/tests/fixtures/canonical-manga.md index 1e4a9cd..f892d3d 100644 --- a/tests/fixtures/canonical-manga.md +++ b/tests/fixtures/canonical-manga.md @@ -31,6 +31,7 @@ tags: [mangas, manga] **Manga** · Publishing · 8.7 **Read Status:** Reading +**Rating:** ⭐️⭐️⭐️⭐️ (4/5) **Progress:** ch. 210 / 213 ## Synopsis diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index becdc04..aea1715 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -149,6 +149,15 @@ describe('renderBook golden', () => { expect(out).toContain('## My Notes\n\nreread every few years'); }); + test('rating line present when rating set', () => { + expect(renderBook(RECORD, '')).toContain('**Rating:** ⭐️⭐️⭐️⭐️⭐️ (5/5)'); + }); + + test('rating 0 -> Rating line absent', () => { + const r = { ...RECORD, rating: '0', ratingStars: '' }; + expect(renderBook(r, '')).not.toContain('**Rating:**'); + }); + test('no goodreads url -> Links has only Open Library entry', () => { const out = renderBook(RECORD, ''); expect(out).toContain('- [Open Library](https://openlibrary.org/works/OL1168083W)'); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index 5540f41..29dc7d2 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -171,6 +171,15 @@ describe('renderComic golden', () => { expect(out).toContain('## My Notes\n\nlove this reboot'); }); + test('rating line present when rating set', () => { + expect(renderComic(RECORD, '')).toContain('**Rating:** ⭐️⭐️⭐️⭐️ (4/5)'); + }); + + test('rating 0 -> Rating line absent', () => { + const r = { ...RECORD, rating: '0', ratingStars: '' }; + expect(renderComic(r, '')).not.toContain('**Rating:**'); + }); + test('no last_read_issue -> Progress line omitted', () => { const r = { ...RECORD, lastReadIssue: '' }; expect(renderComic(r, '')).not.toContain('**Progress:**'); diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index a1fd0d2..f9a994c 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -196,6 +196,39 @@ describe('renderGame golden', () => { expect(out).toContain('## My Notes\n\nplayed co-op with a friend'); }); + test('rating line present when rating set', () => { + expect(renderGame(RECORD, '')).toContain('**Rating:** ⭐️⭐️⭐️⭐️ (4/5)'); + }); + + test('rating 0 -> Rating line absent', () => { + const r = { ...RECORD, rating: '0', ratingStars: '' }; + expect(renderGame(r, '')).not.toContain('**Rating:**'); + }); + + test('links present w/ steam appid', () => { + expect(renderGame(RECORD, '')).toContain('- [Steam](https://store.steampowered.com/app/792100/)'); + }); + + test('links absent when no ids', () => { + const r = { ...RECORD, steamAppid: '', rawgId: '', url: '' }; + const out = renderGame(r, ''); + expect(out).not.toContain('## Links'); + }); + + test('rawg link uses url field verbatim when rawg_id set', () => { + const r = { ...RECORD, steamAppid: '', rawgId: '4200', url: 'https://rawg.io/games/4200' }; + const out = renderGame(r, ''); + expect(out).toContain('- [RAWG](https://rawg.io/games/4200)'); + expect(out).not.toContain('[Steam]'); + }); + + test('both ids set -> both Steam and RAWG links present', () => { + const r = { ...RECORD, rawgId: '4200', url: 'https://rawg.io/games/4200' }; + const out = renderGame(r, ''); + expect(out).toContain('- [Steam](https://store.steampowered.com/app/792100/)'); + expect(out).toContain('- [RAWG](https://rawg.io/games/4200)'); + }); + test('no poster -> poster line omitted', () => { const r = { ...RECORD, poster: null }; expect(renderGame(r, '')).not.toContain('![poster'); @@ -433,6 +466,17 @@ describe('gameSpec.sync — steam enrich', () => { const result = await gameSpec.sync(ctxFor({ steam_appid: '792100' }, '## My Notes\n\nco-op is great'), deps); expect(result!.content).toContain('## My Notes\n\nco-op is great'); }); + + test('user-added ## Links section in body is owned/replaced by computed links; other custom sections unaffected', async () => { + const deps = makeDeps({ http: async () => steamFixture }); + const body = '## Links\n- [Old link](https://example.com)\n\n## Collection\nPart of [[Games]]\n\n## My Notes\n\n'; + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100' }, body), deps); + const content = result!.content; + expect((content.match(/## Links/g) ?? []).length).toBe(1); + expect(content).toContain('- [Steam](https://store.steampowered.com/app/792100/)'); + expect(content).not.toContain('Old link'); + expect(content).toContain('## Collection\nPart of [[Games]]\n'); + }); }); describe('gameSpec.sync — rawg enrich', () => { diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index 08c3b6e..29d4f06 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -185,6 +185,15 @@ describe('renderManga golden', () => { expect(out).toContain('## My Notes\n\ncurrently reading'); }); + test('rating line present when rating set', () => { + expect(renderManga(RECORD, '')).toContain('**Rating:** ⭐️⭐️⭐️⭐️ (4/5)'); + }); + + test('rating 0 -> Rating line absent', () => { + const r = { ...RECORD, rating: '0', ratingStars: '' }; + expect(renderManga(r, '')).not.toContain('**Rating:**'); + }); + test('no last_read_chapter -> Progress line omitted', () => { const r = { ...RECORD, lastReadChapter: '' }; expect(renderManga(r, '')).not.toContain('**Progress:**'); @@ -391,20 +400,34 @@ describe('mangaSpec.sync — chapter source priority', () => { }); describe('mangaSpec.sync — finish-flip', () => { - test('prev Publishing + new Finished + read_status Read -> Unread, flipped', async () => { + test('prev Publishing + new Finished + read_status Read -> Unread, flipped, notify', 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'); + expect(deps.notifyCalls).toEqual(['«Chainsaw Man» finished — final chapters out']); }); - test('prev Publishing + new Finished + read_status Reading -> no flip', async () => { + test('prev Publishing + new Finished + read_status Reading -> no flip, no notify', 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'); + expect(deps.notifyCalls).toEqual([]); + }); + test('chapter-flip and finish-flip both eligible in same sync -> notify fires once (chapter message only, no double-fire)', async () => { + const deps = makeDeps({ + http: async () => ({ data: { ...JIKAN_DATA, status: 'Finished' } }), + httpText: async () => RSS_214, + }); + const fm = { mal_id: '116778', rss: 'https://x.y/f.xml', status: 'Publishing', 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('read_status: Unread'); + expect(result!.content).toContain('status: Finished'); + expect(deps.notifyCalls).toEqual(['«Chainsaw Man» ch. 214 out']); }); });