feat(library): rating line, manga finish-flip notify, game links, dedupe

- render **Rating:** line in manga/book/game/comic body (after read/play
  status, gated on rating != '0' + stars non-empty), matching watchlist's
  render.ts convention
- manga finish-flip now notifies user (final chapters out), matching
  chapter-flip's existing notify path
- game renderGame emits ## Links (Steam by appid, RAWG by url) — fixes
  latent owned-heading strip where user-added Links sections silently
  vanished since games never re-rendered the heading
- move duplicated deriveReadStatus/deriveRating/parseNumOrNull into
  library/convert.ts, import across specs; zero behavior change
This commit is contained in:
afiqzudinhadi 2026-08-03 16:29:02 +08:00
parent d2a6940270
commit c14da19c47
13 changed files with 140 additions and 83 deletions

View file

@ -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, 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: '' };
}
function deriveAuthors(doc: any, prev: Record<string, string>): 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})`);

View file

@ -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, 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
@ -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}`);

View file

@ -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, 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';
}
/** Shared across manga/book/game/comic: canonical rating + stars, with legacy numeric `personalRating` skeleton fallback. */
export 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: '' };
}

View file

@ -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<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: '' };
}
function derivePlayStatus(prev: Record<string, string>): 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('');

View file

@ -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, 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: '' };
}
/**
* 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));