From 071c76862a8cf2c246495712013c2ef55a3080b7 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 29 Jul 2026 19:18:59 +0800 Subject: [PATCH 01/53] rebrand: media-db-sync fork + fix bun test resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest/manifest-beta/package.json → id media-db-sync, v0.1.0 - pluginName const → media-db-sync - tsconfig: baseUrl (bun test needs it for packages/* paths) + ignoreDeprecations 6.0 - upstream tests were broken on clean checkout (module resolution) → 31/31 pass now --- manifest-beta.json | 13 ++++++------- manifest.json | 13 ++++++------- package.json | 4 ++-- packages/obsidian/src/utils/Utils.ts | 2 +- tsconfig.json | 2 ++ 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/manifest-beta.json b/manifest-beta.json index d633e00..9482269 100644 --- a/manifest-beta.json +++ b/manifest-beta.json @@ -1,11 +1,10 @@ { - "id": "obsidian-media-db-plugin", - "name": "Media DB", - "version": "0.8.0-canary.20260703T094912", + "id": "media-db-sync", + "name": "Media DB Sync", + "version": "0.1.0-beta.1", "minAppVersion": "1.12.0", - "description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.", - "author": "Moritz Jung", - "authorUrl": "https://www.moritzjung.dev", - "fundingUrl": "https://github.com/sponsors/mProjectsCode", + "description": "Media library with automatic metadata enrichment, periodic sync, and watch-status automation. Fork of Media DB by Moritz Jung.", + "author": "Afiq Zudin Hadi", + "authorUrl": "https://github.com/afiqzudinhadi", "isDesktopOnly": false } diff --git a/manifest.json b/manifest.json index 5e91e7f..1fa516a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,10 @@ { - "id": "obsidian-media-db-plugin", - "name": "Media DB", - "version": "0.8.0", + "id": "media-db-sync", + "name": "Media DB Sync", + "version": "0.1.0", "minAppVersion": "1.12.0", - "description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.", - "author": "Moritz Jung", - "authorUrl": "https://www.moritzjung.dev", - "fundingUrl": "https://github.com/sponsors/mProjectsCode", + "description": "Media library with automatic metadata enrichment, periodic sync, and watch-status automation. Fork of Media DB by Moritz Jung.", + "author": "Afiq Zudin Hadi", + "authorUrl": "https://github.com/afiqzudinhadi", "isDesktopOnly": false } diff --git a/package.json b/package.json index f335e85..5f963a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "obsidian-media-db-plugin", - "version": "0.8.0", + "name": "media-db-sync", + "version": "0.1.0", "description": "A plugin that can query multiple APIs for movies, series, anime, manga, books, comics, games, music and wiki articles, and import them into your vault.", "main": "main.js", "scripts": { diff --git a/packages/obsidian/src/utils/Utils.ts b/packages/obsidian/src/utils/Utils.ts index cc5b2bd..5e8a804 100644 --- a/packages/obsidian/src/utils/Utils.ts +++ b/packages/obsidian/src/utils/Utils.ts @@ -3,7 +3,7 @@ import type { TFile, TFolder, App } from 'obsidian'; import { requestUrl } from 'obsidian'; import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel'; -export const pluginName: string = 'obsidian-media-db-plugin'; +export const pluginName: string = 'media-db-sync'; export const contactEmail: string = 'm.projects.code@gmail.com'; export const mediaDbTag: string = 'mediaDB'; export const mediaDbVersion: string = '0.8.0'; diff --git a/tsconfig.json b/tsconfig.json index bb7ef48..f570a40 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { + "baseUrl": ".", + "ignoreDeprecations": "6.0", "paths": { "packages/*": ["./packages/*"], "tests/*": ["./tests/*"] From e7714736ce54bd706a19511bdeed3c91d9662fa8 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 29 Jul 2026 21:06:54 +0800 Subject: [PATCH 02/53] docs: watchlist suite implementation plan --- .../plans/2026-07-29-watchlist-suite.md | 1580 +++++++++++++++++ 1 file changed, 1580 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-29-watchlist-suite.md diff --git a/docs/superpowers/plans/2026-07-29-watchlist-suite.md b/docs/superpowers/plans/2026-07-29-watchlist-suite.md new file mode 100644 index 0000000..81eec32 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-watchlist-suite.md @@ -0,0 +1,1580 @@ +# Watchlist Suite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add full metadata enrichment, periodic diff-on-write sync, and watch-status automation (Watched→Unwatched on new episode) to the media-db-sync fork, emitting the canonical watchlist schema. + +**Architecture:** New self-contained module `packages/obsidian/src/watchlist/` — pure functions (TMDB JSON → record → note string) with HTTP and vault I/O injected, plus a `SyncEngine` orchestrator. Thin wiring into `main.ts` (commands + `registerInterval` catch-up scheduler) and `Settings.ts`. Port of the proven Python reference (`watchlist_sync.py`) with three additions: `seasons`/`episodes`/`vod` fields, tiered sync, resolve-missing-ids. + +**Tech Stack:** TypeScript, bun test (preload `tests/setup.ts` mocks `obsidian`), vite build, Obsidian `requestUrl` via existing `obsidianFetch` (`packages/obsidian/src/utils/Utils.ts:283`). + +## Global Constraints + +- ALL HTTP via Obsidian `requestUrl` (`obsidianFetch`) — never node `fetch` (ISP DNS blocks TMDB for node stack). +- TMDB auth BOTH: key starts `eyJ` → v4 `Authorization: Bearer` header; else v3 `&api_key=` query param. +- Preserve on every write: `watch_status` (except TV rule), `rating`, `rating_stars`, `notion_url`, body `## My Notes`. +- Schema = exact canonical frontmatter (see `tests/fixtures/canonical-series.md`). Always emit `seasons`/`episodes`/`vod` (movies: `null`/`null`/`[]`) — verified against real vault notes. +- Dates stay ISO strings end-to-end; compare lexicographically. No `Date` parsing. +- Diff-on-write: render full note, string-compare vs existing content, write only when different. +- Test commands: `bun run test` (NEVER raw `bun test` — misses preload), `bun run tsc`, `bun run build`. All three must pass before every commit. +- Reference implementation: `/Users/AfiqZudinHadi/Documents/obsidian-media-db/reference/watchlist_sync.py`. Field mappings spec: `/Users/AfiqZudinHadi/Documents/obsidian-media-db/docs/SPEC.md`. +- Commit format: end body with `Co-Authored-By: Claude Fable 5 `. + +--- + +### Task 1: YAML helpers + schema types + +**Files:** +- Create: `packages/obsidian/src/watchlist/schema.ts` +- Create: `packages/obsidian/src/watchlist/yaml.ts` +- Test: `tests/watchlist-yaml.test.ts` + +**Interfaces:** +- Produces: `WatchlistRecord` interface (all downstream tasks build/consume it); `yamlScalar(v: unknown): string`, `yamlList(items: string[]): string`, `quotedOrNull(v: string | null | undefined): string`. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/watchlist-yaml.test.ts +import { describe, expect, test } from 'bun:test'; +import { yamlScalar, yamlList, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; + +describe('yamlScalar', () => { + test('plain string passes through', () => { + expect(yamlScalar('English')).toBe('English'); + }); + test('empty/null/undefined → empty string', () => { + expect(yamlScalar('')).toBe(''); + expect(yamlScalar(null)).toBe(''); + expect(yamlScalar(undefined)).toBe(''); + }); + test('special chars → quoted', () => { + expect(yamlScalar('S2, E6: Glorious Purpose')).toBe('"S2, E6: Glorious Purpose"'); + expect(yamlScalar('Aaron Moorhead, Justin Benson')).toBe('"Aaron Moorhead, Justin Benson"'); + }); + test('comma alone does NOT quote (matches python regex)', () => { + // python regex [:#\[\]{}",&*!|>%@`] has no comma; "a, b" quotes via ":"? no — verify: comma not in class, so "Alter, Turtle" stays plain + expect(yamlScalar('Rachel Alter and Tommy Turtle')).toBe('Rachel Alter and Tommy Turtle'); + }); + test('yaml keywords → quoted', () => { + expect(yamlScalar('null')).toBe('"null"'); + expect(yamlScalar('No')).toBe('"No"'); + }); + test('embedded quotes escaped', () => { + expect(yamlScalar('He said "hi"')).toBe('"He said \\"hi\\""'); + }); +}); + +describe('yamlList', () => { + test('plain items unquoted, special quoted', () => { + expect(yamlList(['Drama', 'Sci-Fi & Fantasy'])).toBe('[Drama, "Sci-Fi & Fantasy"]'); + }); + test('empty list', () => { + expect(yamlList([])).toBe('[]'); + }); + test('blank items dropped', () => { + expect(yamlList(['', 'Drama', ' '])).toBe('[Drama]'); + }); + test('Disney+ quoted', () => { + expect(yamlList(['Disney+'])).toBe('["Disney+"]'); + }); +}); + +describe('quotedOrNull', () => { + test('value → quoted', () => { + expect(quotedOrNull('https://x.y/z')).toBe('"https://x.y/z"'); + }); + test('empty/null → null literal', () => { + expect(quotedOrNull('')).toBe('null'); + expect(quotedOrNull(null)).toBe('null'); + }); +}); +``` + +NOTE for implementer: the "comma alone" test documents python behavior — `,` is NOT in the quoting regex. `'S2, E6: Glorious Purpose'` quotes because of `:`. `'Aaron Moorhead, Justin Benson'` — check python regex: no comma, no colon in that string… BUT the canonical fixture shows `director: "Aaron Moorhead, Justin Benson"` QUOTED. Reality: the vault was written by an earlier variant that quoted on comma. **Decision: add `,` to the quoting regex** so output matches the real vault. Test expectations above are written for the comma-quoting variant (`'Rachel Alter and Tommy Turtle'` has no comma → plain). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test 2>&1 | tail -5` +Expected: FAIL — `Cannot find module 'packages/obsidian/src/watchlist/yaml'` + +- [ ] **Step 3: Write minimal implementation** + +```ts +// packages/obsidian/src/watchlist/yaml.ts +const NEEDS_QUOTE = /[:,#\[\]{}",&*!|>%@`]/; +const KEYWORDS = new Set(['null', 'true', 'false', 'yes', 'no']); + +export function yamlScalar(v: unknown): string { + if (v === null || v === undefined || v === '') return ''; + const s = String(v); + if (NEEDS_QUOTE.test(s) || s.trim() !== s || KEYWORDS.has(s.toLowerCase())) { + return '"' + s.replace(/"/g, '\\"') + '"'; + } + return s; +} + +export function yamlList(items: string[]): string { + const parts: string[] = []; + for (const raw of items) { + const g = String(raw).trim(); + if (!g) continue; + parts.push(/^[A-Za-z0-9 ]+$/.test(g) ? g : '"' + g.replace(/"/g, '\\"') + '"'); + } + return '[' + parts.join(', ') + ']'; +} + +export function quotedOrNull(v: string | null | undefined): string { + return v ? '"' + v + '"' : 'null'; +} +``` + +```ts +// packages/obsidian/src/watchlist/schema.ts +export type WatchCategory = 'Movie' | 'Series' | 'Anime'; +export type WatchMediaType = 'Movie' | 'TV Series'; + +export interface WatchlistRecord { + title: string; + engName: string; + mediaType: WatchMediaType; + category: WatchCategory; + watchStatus: string; + rating: string; + ratingStars: string; + year: string; + runtime: number | null; + seasons: number | null; + episodes: number | null; + vod: string[]; + genre: string[]; + status: string; + language: string; + country: string; + director: string[]; + writer: string[]; + producer: string[]; + contentRating: string; + tmdbRating: number | null; + tmdbId: string; + imdbId: string; + releaseDate: string | null; + lastAirDate: string | null; + nextAirDate: string | null; + lastEpisode: string | null; + upcomingEpisode: string | null; + poster: string | null; + trailer: string; + homepage: string; + imdbPage: string; + notionUrl: string; + synopsis: string; + cast: string; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test 2>&1 | tail -5` +Expected: all pass (31 upstream + new) + +- [ ] **Step 5: Commit** + +```bash +git add packages/obsidian/src/watchlist/ tests/watchlist-yaml.test.ts +git commit -m "feat(watchlist): yaml helpers + schema types" +``` + +--- + +### Task 2: Note parsing (frontmatter, My Notes, TMDB ref) + +**Files:** +- Create: `packages/obsidian/src/watchlist/parse.ts` +- Test: `tests/watchlist-parse.test.ts` + +**Interfaces:** +- Produces: `parseNote(content: string): { frontmatter: Record; body: string }`; `extractMyNotes(body: string): string`; `noteTmdbRef(fm: Record): { tmdbId: string; isMovie: boolean } | null`. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/watchlist-parse.test.ts +import { describe, expect, test } from 'bun:test'; +import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; + +const NOTE = `--- +type: watchlist_item +media_type: TV Series +tmdb_id: 84958 +last_air_date: 2023-11-09 +watch_status: Watched +--- + +# Loki + +## My Notes + +great finale +`; + +describe('parseNote', () => { + test('splits frontmatter and body', () => { + const { frontmatter, body } = parseNote(NOTE); + expect(frontmatter['tmdb_id']).toBe('84958'); + expect(frontmatter['media_type']).toBe('TV Series'); + expect(body).toContain('# Loki'); + }); + test('no frontmatter → empty fm, full body', () => { + const { frontmatter, body } = parseNote('# Just a heading'); + expect(Object.keys(frontmatter).length).toBe(0); + expect(body).toBe('# Just a heading'); + }); +}); + +describe('extractMyNotes', () => { + test('extracts trailing section', () => { + expect(extractMyNotes(parseNote(NOTE).body)).toBe('great finale'); + }); + test('missing section → empty', () => { + expect(extractMyNotes('# T\n\ncontent')).toBe(''); + }); +}); + +describe('noteTmdbRef', () => { + test('canonical note', () => { + expect(noteTmdbRef({ tmdb_id: '84958', media_type: 'TV Series' })).toEqual({ tmdbId: '84958', isMovie: false }); + expect(noteTmdbRef({ tmdb_id: '693134', media_type: 'Movie' })).toEqual({ tmdbId: '693134', isMovie: true }); + }); + test('quoted values stripped', () => { + expect(noteTmdbRef({ tmdb_id: '"84958"', media_type: '"TV Series"' })).toEqual({ tmdbId: '84958', isMovie: false }); + }); + test('raw Media DB note fallback (id + dataSource)', () => { + expect(noteTmdbRef({ id: '693134', dataSource: 'TMDBMovieAPI' })).toEqual({ tmdbId: '693134', isMovie: true }); + expect(noteTmdbRef({ id: '84958', dataSource: 'TMDBSeriesAPI' })).toEqual({ tmdbId: '84958', isMovie: false }); + }); + test('no id → null', () => { + expect(noteTmdbRef({ type: 'list' })).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test 2>&1 | tail -5` +Expected: FAIL — `Cannot find module 'packages/obsidian/src/watchlist/parse'` + +- [ ] **Step 3: Write minimal implementation** + +```ts +// packages/obsidian/src/watchlist/parse.ts +export interface ParsedNote { + frontmatter: Record; + body: string; +} + +export function parseNote(content: string): ParsedNote { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(content); + const frontmatter: Record = {}; + let body = content; + if (m) { + body = m[2]; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) frontmatter[mm[1]] = mm[2].trim(); + } + } + return { frontmatter, body }; +} + +export function extractMyNotes(body: string): string { + const m = /##\s*My Notes\s*([\s\S]*)$/.exec(body ?? ''); + return m ? m[1].trim() : ''; +} + +function stripQuotes(s: string | undefined): string { + return (s ?? '').trim().replace(/^"|"$/g, ''); +} + +export function noteTmdbRef(fm: Record): { tmdbId: string; isMovie: boolean } | null { + const tid = stripQuotes(fm['tmdb_id']); + if (tid) return { tmdbId: tid, isMovie: stripQuotes(fm['media_type']) === 'Movie' }; + const rawId = stripQuotes(fm['id']); + const ds = (fm['dataSource'] ?? '').trim(); + if (rawId && ds.startsWith('TMDB')) return { tmdbId: rawId, isMovie: ds.includes('Movie') }; + return null; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test 2>&1 | tail -5` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/obsidian/src/watchlist/parse.ts tests/watchlist-parse.test.ts +git commit -m "feat(watchlist): note parsing (frontmatter, My Notes, tmdb ref)" +``` + +--- + +### Task 3: buildRecord — TMDB detail → WatchlistRecord + +**Files:** +- Create: `packages/obsidian/src/watchlist/build.ts` +- Create: `tests/fixtures/tmdb-movie-dune2.json` (mock payload — copy verbatim from `watchlist_sync.py` `selftest()` L301-316, JSON-ified: `True`→`true`) +- Create: `tests/fixtures/tmdb-tv-loki.json` (mock below) +- Test: `tests/watchlist-build.test.ts` + +**Interfaces:** +- Consumes: `WatchlistRecord` (Task 1). +- Produces: `buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record): WatchlistRecord` where `TmdbDetail = Record` (raw TMDB JSON). + +`tests/fixtures/tmdb-tv-loki.json`: + +```json +{ + "id": 84958, "name": "Loki", "original_name": "Loki", + "original_language": "en", + "spoken_languages": [{ "iso_639_1": "en", "english_name": "English" }], + "overview": "After stealing the Tesseract...", + "episode_run_time": [], "status": "Ended", + "first_air_date": "2021-06-09", "last_air_date": "2023-11-09", + "number_of_seasons": 2, "number_of_episodes": 12, + "networks": [{ "name": "Disney+" }], + "vote_average": 8.2, "homepage": "https://www.disneyplus.com/series/wp/6pARMvILBGzF", + "poster_path": null, + "genres": [{ "name": "Drama" }, { "name": "Sci-Fi & Fantasy" }], + "origin_country": ["United States of America"], + "created_by": [{ "name": "Michael Waldron" }], + "aggregate_credits": { "cast": [{ "name": "Tom Hiddleston" }, { "name": "Sophia Di Martino" }] }, + "external_ids": { "imdb_id": "tt9140554" }, + "content_ratings": { "results": [{ "iso_3166_1": "US", "rating": "TV-14" }] }, + "videos": { "results": [{ "site": "YouTube", "type": "Trailer", "official": true, "key": "nW948Va-l10" }] }, + "last_episode_to_air": { "season_number": 2, "episode_number": 6, "name": "Glorious Purpose" }, + "next_episode_to_air": null +} +``` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/watchlist-build.test.ts +import { describe, expect, test } from 'bun:test'; +import { buildRecord } from 'packages/obsidian/src/watchlist/build'; +import movieDetail from 'tests/fixtures/tmdb-movie-dune2.json'; +import tvDetail from 'tests/fixtures/tmdb-tv-loki.json'; + +const EMPTY_PREV = { watch_status: 'Unwatched', rating: '0', rating_stars: '' }; + +describe('buildRecord movie', () => { + const r = buildRecord(movieDetail, true, EMPTY_PREV); + test('core mapping (mirrors python selftest)', () => { + expect(r.language).toBe('English'); + expect(r.country).toBe('United States of America'); + expect(r.imdbId).toBe('tt15239678'); + expect(r.contentRating).toBe('PG-13'); + expect(r.tmdbId).toBe('693134'); + expect(r.category).toBe('Movie'); + expect(r.trailer).toContain('youtube.com'); + expect(r.year).toBe('2024'); + expect(r.director).toEqual(['Denis Villeneuve']); + expect(r.writer).toEqual(['Jon Spaihts']); + expect(r.producer).toEqual(['Mary Parent']); + expect(r.imdbPage).toBe('https://www.imdb.com/title/tt15239678/'); + }); + test('movie: seasons/episodes null, vod empty', () => { + expect(r.seasons).toBeNull(); + expect(r.episodes).toBeNull(); + expect(r.vod).toEqual([]); + }); +}); + +describe('buildRecord tv', () => { + const r = buildRecord(tvDetail, false, EMPTY_PREV); + test('tv mapping', () => { + expect(r.mediaType).toBe('TV Series'); + expect(r.category).toBe('Series'); + expect(r.year).toBe('2021 - 2023'); + expect(r.seasons).toBe(2); + expect(r.episodes).toBe(12); + expect(r.vod).toEqual(['Disney+']); + expect(r.contentRating).toBe('TV-14'); + expect(r.lastEpisode).toBe('S2, E6: Glorious Purpose'); + expect(r.upcomingEpisode).toBeNull(); + expect(r.runtime).toBeNull(); + expect(r.director).toEqual(['Michael Waldron']); + }); + test('ongoing series year + TBA', () => { + const ongoing = { ...tvDetail, status: 'Returning Series', last_air_date: '2026-01-01', next_episode_to_air: null }; + const r2 = buildRecord(ongoing, false, EMPTY_PREV); + expect(r2.year).toBe('2021 -'); + expect(r2.upcomingEpisode).toBe('TBA'); + }); + test('same start/end year collapses', () => { + const oneYear = { ...tvDetail, first_air_date: '2021-06-09', last_air_date: '2021-07-14' }; + expect(buildRecord(oneYear, false, EMPTY_PREV).year).toBe('2021'); + }); +}); + +describe('anime derivation', () => { + test('Animation + Japanese → Anime', () => { + const anime = { + ...tvDetail, + genres: [{ name: 'Animation' }, { name: 'Drama' }], + original_language: 'ja', + spoken_languages: [{ iso_639_1: 'ja', english_name: 'Japanese' }], + }; + expect(buildRecord(anime, false, EMPTY_PREV).category).toBe('Anime'); + }); + test('Animation + English → not Anime', () => { + const western = { ...tvDetail, genres: [{ name: 'Animation' }] }; + expect(buildRecord(western, false, EMPTY_PREV).category).toBe('Series'); + }); +}); + +describe('user-field preservation', () => { + test('prev fields carried', () => { + const prev = { watch_status: 'Watching', rating: '4', rating_stars: '⭐️⭐️⭐️⭐️', notion_url: 'https://notion.so/x' }; + const r = buildRecord(tvDetail, false, prev); + expect(r.watchStatus).toBe('Watching'); + expect(r.rating).toBe('4'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + expect(r.notionUrl).toBe('https://notion.so/x'); + }); +}); + +describe('watch-status rule (TV)', () => { + test('Watched + newer episode → Unwatched', () => { + const prev = { watch_status: 'Watched', last_air_date: '2023-10-01', rating: '5', rating_stars: '⭐️⭐️⭐️⭐️⭐️' }; + expect(buildRecord(tvDetail, false, prev).watchStatus).toBe('Unwatched'); + }); + test('Watched + same date → stays Watched', () => { + const prev = { watch_status: 'Watched', last_air_date: '2023-11-09' }; + expect(buildRecord(tvDetail, false, prev).watchStatus).toBe('Watched'); + }); + test('movie never flips', () => { + const prev = { watch_status: 'Watched', last_air_date: '2020-01-01' }; + expect(buildRecord(movieDetail, true, prev).watchStatus).toBe('Watched'); + }); + test('no prev last_air_date → no flip', () => { + const prev = { watch_status: 'Watched' }; + expect(buildRecord(tvDetail, false, prev).watchStatus).toBe('Watched'); + }); +}); + +describe('eng_name derivation', () => { + test('non-Latin original → engName = localized title', () => { + const jp = { ...movieDetail, title: 'A Silent Voice: The Movie', original_title: '映画 聲の形' }; + expect(buildRecord(jp, true, EMPTY_PREV).engName).toBe('A Silent Voice: The Movie'); + }); + test('same Latin title → empty', () => { + expect(buildRecord(movieDetail, true, EMPTY_PREV).engName).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test 2>&1 | tail -5` +Expected: FAIL — module not found + +- [ ] **Step 3: Write implementation (direct port of `build_record`, watchlist_sync.py L120-214, + seasons/episodes/vod)** + +```ts +// packages/obsidian/src/watchlist/build.ts +import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; + +export type TmdbDetail = Record; + +const IMG_BASE = 'https://image.tmdb.org/t/p/original'; + +const LANG_FALLBACK: Record = { + en: 'English', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', fr: 'French', + es: 'Spanish', de: 'German', hi: 'Hindi', ta: 'Tamil', th: 'Thai', +}; + +function langName(details: TmdbDetail): string { + const code: string = details.original_language ?? ''; + for (const sl of details.spoken_languages ?? []) { + if (sl.iso_639_1 === code && sl.english_name) return sl.english_name; + } + return LANG_FALLBACK[code] ?? code; +} + +function crewNames(crew: any[], jobs: Set): string[] { + const seen = new Set(); + const out: string[] = []; + for (const c of crew) { + if (jobs.has(c.job) && !seen.has(c.name)) { + seen.add(c.name); + out.push(c.name); + } + } + return out; +} + +function usCertFromReleaseDates(rd: TmdbDetail): string { + for (const entry of rd?.results ?? []) { + if (entry.iso_3166_1 === 'US') { + for (const d of entry.release_dates ?? []) { + if (d.certification) return d.certification; + } + } + } + return ''; +} + +function usCertFromContentRatings(cr: TmdbDetail): string { + for (const entry of cr?.results ?? []) { + if (entry.iso_3166_1 === 'US' && entry.rating) return entry.rating; + } + return ''; +} + +function pickTrailer(videos: TmdbDetail): string { + const vids: any[] = videos?.results ?? []; + const yt = (v: any): string => 'https://www.youtube.com/watch?v=' + v.key; + for (const v of vids) if (v.site === 'YouTube' && v.type === 'Trailer' && v.official) return yt(v); + for (const v of vids) if (v.site === 'YouTube' && v.type === 'Trailer') return yt(v); + return ''; +} + +export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record): WatchlistRecord { + const genres: string[] = (details.genres ?? []).map((g: any) => g.name); + const language = langName(details); + const imdbId: string = details.external_ids?.imdb_id ?? ''; + const trailer = pickTrailer(details.videos ?? {}); + + let title: string, originalTitle: string, mediaType: 'Movie' | 'TV Series'; + let releaseDate: string | null, runtime: number | null, status: string, contentRating: string, country: string; + let cast: string[], director: string[], writer: string[], producer: string[]; + let seasons: number | null, episodes: number | null, vod: string[]; + let lastAirDate: string | null = null, nextAirDate: string | null = null; + let lastEpisode: string | null = null, upEpisode: string | null = null; + let yearDisp: string; + + if (isMovie) { + const credits = details.credits ?? {}; + cast = (credits.cast ?? []).slice(0, 12).map((c: any) => c.name); + const crew: any[] = credits.crew ?? []; + director = crewNames(crew, new Set(['Director'])); + writer = crewNames(crew, new Set(['Writer', 'Screenplay', 'Story'])); + producer = crewNames(crew, new Set(['Producer'])); + title = details.title ?? ''; + originalTitle = details.original_title ?? ''; + mediaType = 'Movie'; + releaseDate = details.release_date || null; + runtime = details.runtime || null; + status = details.status ?? ''; + contentRating = usCertFromReleaseDates(details.release_dates ?? {}); + const countries: string[] = (details.production_countries ?? []).map((c: any) => c.name); + country = countries[0] ?? ''; + yearDisp = (releaseDate ?? '').slice(0, 4); + seasons = null; + episodes = null; + vod = []; + } else { + const agg = details.aggregate_credits ?? {}; + cast = (agg.cast ?? []).slice(0, 12).map((c: any) => c.name); + const createdBy: string[] = (details.created_by ?? []).map((c: any) => c.name); + director = createdBy; // series: creators (latest-episode director needs extra call — Phase 3) + writer = createdBy; + producer = []; + title = details.name ?? ''; + originalTitle = details.original_name ?? ''; + mediaType = 'TV Series'; + releaseDate = details.first_air_date || null; + const rt: number[] = details.episode_run_time ?? []; + runtime = rt.length > 0 ? rt[0] : null; + status = details.status ?? ''; + contentRating = usCertFromContentRatings(details.content_ratings ?? {}); + const countries: string[] = details.origin_country ?? []; + country = countries[0] ?? ''; + seasons = details.number_of_seasons ?? null; + episodes = details.number_of_episodes ?? null; + vod = (details.networks ?? []).map((n: any) => n.name); + lastAirDate = details.last_air_date || null; + const le = details.last_episode_to_air; + const ne = details.next_episode_to_air; + lastEpisode = le ? `S${le.season_number}, E${le.episode_number}: ${le.name}` : null; + upEpisode = ne + ? `S${ne.season_number}, E${ne.episode_number}: ${ne.name}` + : ['Returning Series', 'Pilot'].includes(status) + ? 'TBA' + : null; + nextAirDate = ne?.air_date ?? null; + const start = (releaseDate ?? '').slice(0, 4); + const ended = ['Ended', 'Canceled', 'Cancelled'].includes(status); + const end = ended ? (lastAirDate ?? '').slice(0, 4) : null; + yearDisp = end && end !== start ? `${start} - ${end}` : start && !ended ? `${start} -` : start; + } + + const category: 'Movie' | 'Series' | 'Anime' = + genres.includes('Animation') && language === 'Japanese' ? 'Anime' : isMovie ? 'Movie' : 'Series'; + + const poster = details.poster_path ? IMG_BASE + details.poster_path : null; + let engName = ''; + // eslint-disable-next-line no-control-regex + if (originalTitle && originalTitle !== title && !/^[\x00-\x7F ]+$/.test(originalTitle)) { + engName = title; // original is non-Latin → english name is the localized title + } + + // ---- preserve user-managed fields ---- + let watchStatus = prev['watch_status'] || 'Unwatched'; + const rating = prev['rating'] || '0'; + const ratingStars = prev['rating_stars'] ?? ''; + const notionUrl = prev['notion_url'] || ''; + + // ---- TV watch-status rule: new episode aired since last sync ---- + const prevLast = (prev['last_air_date'] ?? '').trim().replace(/^"|"$/g, ''); + if (!isMovie && watchStatus === 'Watched' && lastAirDate && prevLast && lastAirDate > prevLast) { + watchStatus = 'Unwatched'; + } + + return { + title, engName, mediaType, category, + watchStatus, rating, ratingStars, + year: yearDisp, runtime, seasons, episodes, vod, genre: genres, status, + language, country, director, writer, producer, contentRating, + tmdbRating: details.vote_average ?? null, tmdbId: String(details.id), + imdbId, releaseDate, lastAirDate, nextAirDate, lastEpisode, upcomingEpisode: upEpisode, + poster, trailer, homepage: details.homepage ?? '', + imdbPage: imdbId ? `https://www.imdb.com/title/${imdbId}/` : '', + notionUrl, synopsis: details.overview ?? '', + cast: cast.join(', '), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test 2>&1 | tail -5` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/obsidian/src/watchlist/build.ts tests/watchlist-build.test.ts tests/fixtures/ +git commit -m "feat(watchlist): buildRecord — TMDB detail → canonical record" +``` + +--- + +### Task 4: renderNote — record → note string (golden test) + +**Files:** +- Create: `packages/obsidian/src/watchlist/render.ts` +- Create: `tests/fixtures/canonical-series.md` — copy verbatim from `/Users/AfiqZudinHadi/Documents/obsidian-media-db/reference/sample_canonical_series.md` +- Test: `tests/watchlist-render.test.ts` + +**Interfaces:** +- Consumes: `WatchlistRecord`, yaml helpers. +- Produces: `renderNote(r: WatchlistRecord, myNotes: string): string` — full note content, frontmatter order exactly: `type, category, media_type, watch_status, rating, rating_stars, year, runtime, seasons, episodes, vod, genre, status, language, country, director, writer, producer, content_rating, tmdb_rating, tmdb_id, imdb_id, release_date, last_air_date, next_air_date, last_episode, upcoming_episode, eng_name, poster, trailer, homepage, imdb_page, notion_url, tags`. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/watchlist-render.test.ts +import { describe, expect, test } from 'bun:test'; +import { renderNote } from 'packages/obsidian/src/watchlist/render'; +import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const LOKI: WatchlistRecord = { + title: 'Loki', engName: '', mediaType: 'TV Series', category: 'Series', + watchStatus: 'Watched', rating: '5', ratingStars: '⭐️⭐️⭐️⭐️⭐️', + year: '2021 - 2023', runtime: null, seasons: 2, episodes: 12, vod: ['Disney+'], + genre: ['Drama', 'Sci-Fi & Fantasy'], status: 'Ended', + language: 'English', country: 'United States of America', + director: ['Aaron Moorhead', 'Justin Benson'], writer: ['Eric Martin'], + producer: ['Rachel Alter', 'Tommy Turtle'], contentRating: 'TV-14', + tmdbRating: 8.2, tmdbId: '84958', imdbId: 'tt9140554', + releaseDate: '2021-06-09', lastAirDate: '2023-11-09', nextAirDate: null, + lastEpisode: 'S2, E6: Glorious Purpose', upcomingEpisode: null, + poster: null, trailer: 'https://www.youtube.com/watch?v=nW948Va-l10', + homepage: 'https://www.disneyplus.com/series/wp/6pARMvILBGzF', + imdbPage: 'https://www.imdb.com/title/tt9140554/', + notionUrl: 'https://www.notion.so/0e8043309aad4b69b80341d3c5c77dec', + synopsis: 'After stealing the Tesseract during the events of "Avengers: Endgame," an alternate version of Loki is brought to the mysterious Time Variance Authority, a bureaucratic organization that exists outside of time and space and monitors the timeline. They give Loki a choice: face being erased from existence due to being a "time variant" or help fix the timeline and stop a greater threat.', + cast: 'Tom Hiddleston, Sophia Di Martino, Wunmi Mosaku, Eugene Cordero, Ke Huy Quan, Owen Wilson', +}; + +describe('renderNote golden', () => { + test('matches canonical series fixture byte-for-byte', () => { + const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-series.md'), 'utf-8'); + expect(renderNote(LOKI, '')).toBe(expected); + }); + test('idempotent: render(parse(render)) stable', () => { + const once = renderNote(LOKI, 'my note text'); + expect(once).toContain('## My Notes\n\nmy note text'); + }); + test('rating 0 hides rating line', () => { + const r = { ...LOKI, rating: '0', ratingStars: '' }; + expect(renderNote(r, '')).not.toContain('**Rating:**'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test 2>&1 | tail -5` +Expected: FAIL — module not found + +- [ ] **Step 3: Write implementation (port of `render_note`, watchlist_sync.py L216-267, + seasons/episodes/vod lines)** + +```ts +// packages/obsidian/src/watchlist/render.ts +import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; +import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; + +const CATEGORY_TAG: Record = { Movie: 'movie', Series: 'series', Anime: 'anime' }; + +export function renderNote(r: WatchlistRecord, myNotes: string): string { + const tag = CATEGORY_TAG[r.category]; + const stars = r.ratingStars; + const fm = [ + '---', + 'type: watchlist_item', + `category: ${r.category}`, + `media_type: ${r.mediaType}`, + `watch_status: ${r.watchStatus}`, + `rating: ${r.rating}`, + `rating_stars: ${stars}`, + `year: ${r.year || ''}`, + `runtime: ${r.runtime ? r.runtime : 'null'}`, + `seasons: ${r.seasons ?? 'null'}`, + `episodes: ${r.episodes ?? 'null'}`, + `vod: ${yamlList(r.vod)}`, + `genre: ${yamlList(r.genre)}`, + `status: ${r.status}`, + `language: ${yamlScalar(r.language)}`, + `country: ${yamlScalar(r.country)}`, + `director: ${yamlScalar(r.director.join(', '))}`, + `writer: ${yamlScalar(r.writer.join(', '))}`, + `producer: ${yamlScalar(r.producer.join(', '))}`, + `content_rating: ${r.contentRating}`, + `tmdb_rating: ${r.tmdbRating}`, + `tmdb_id: ${r.tmdbId}`, + `imdb_id: ${r.imdbId}`, + `release_date: ${r.releaseDate ? r.releaseDate : 'null'}`, + `last_air_date: ${r.lastAirDate ? r.lastAirDate : 'null'}`, + `next_air_date: ${r.nextAirDate ? r.nextAirDate : 'null'}`, + `last_episode: ${r.lastEpisode ? yamlScalar(r.lastEpisode) : ''}`, + `upcoming_episode: ${r.upcomingEpisode ? yamlScalar(r.upcomingEpisode) : ''}`, + `eng_name: ${yamlScalar(r.engName)}`, + `poster: ${quotedOrNull(r.poster)}`, + `trailer: ${quotedOrNull(r.trailer)}`, + `homepage: ${quotedOrNull(r.homepage)}`, + `imdb_page: ${quotedOrNull(r.imdbPage)}`, + `notion_url: ${quotedOrNull(r.notionUrl)}`, + `tags: [watchlist, ${tag}]`, + '---', + ]; + 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 = [`**${r.category}**`, ...[r.year, r.language, r.country].filter(x => x)]; + b.push(meta.join(' · '), ''); + if (r.rating !== '0' && r.rating !== '' && stars) b.push(`**Rating:** ${stars} (${r.rating}/5)`); + b.push(`**Watch Status:** ${r.watchStatus}`); + if (r.tmdbRating) b.push(`**TMDB Rating:** ${r.tmdbRating}/10`); + b.push(''); + if (r.synopsis) b.push('## Synopsis', r.synopsis, ''); + const crew: string[] = []; + if (r.director.length) crew.push(`**Director:** ${r.director.join(', ')}`); + if (r.writer.length) crew.push(`**Writer:** ${r.writer.join(', ')}`); + if (r.producer.length) crew.push(`**Producer:** ${r.producer.join(', ')}`); + if (crew.length) b.push(...crew, ''); + if (r.cast) b.push('## Cast', r.cast, ''); + const links: string[] = []; + if (r.imdbPage) links.push(`- [IMDb](${r.imdbPage})`); + if (r.trailer) links.push(`- [Trailer](${r.trailer})`); + if (r.homepage) links.push(`- [Homepage](${r.homepage})`); + if (r.notionUrl) links.push(`- [Original Notion entry](${r.notionUrl})`); + if (links.length) b.push('## Links', ...links, ''); + b.push('## My Notes', '', myNotes, ''); + return fm.join('\n') + '\n' + b.join('\n'); +} +``` + +NOTE for implementer: if the golden test fails on whitespace, diff the two strings char-by-char (`Bun.write` both to tmp files, `diff`) and adjust ONLY the fixture-vs-python discrepancies (e.g. trailing spaces after `last_episode:` when empty, final newline). The canonical fixture is the authority — real vault notes look like it. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test 2>&1 | tail -5` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/obsidian/src/watchlist/render.ts tests/watchlist-render.test.ts tests/fixtures/canonical-series.md +git commit -m "feat(watchlist): renderNote — canonical note serializer (golden-tested)" +``` + +--- + +### Task 5: TMDB detail client (auth v3/v4, append_to_response) + +**Files:** +- Create: `packages/obsidian/src/watchlist/tmdb.ts` +- Test: `tests/watchlist-tmdb.test.ts` + +**Interfaces:** +- Consumes: nothing internal (http injected). +- Produces: `type HttpJsonFn = (url: string, headers: Record) => Promise`; `fetchDetail(http: HttpJsonFn, key: string, tmdbId: string, isMovie: boolean): Promise`; `searchTitle(http: HttpJsonFn, key: string, query: string, isMovie: boolean, year?: string): Promise`. +- Plugin passes an adapter over `obsidianFetch` (Task 6/7); tests pass a stub. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/watchlist-tmdb.test.ts +import { describe, expect, test } from 'bun:test'; +import { fetchDetail, searchTitle } from 'packages/obsidian/src/watchlist/tmdb'; + +function capture(): { calls: { url: string; headers: Record }[]; http: any } { + const calls: { url: string; headers: Record }[] = []; + return { + calls, + http: async (url: string, headers: Record) => { + calls.push({ url, headers }); + return { results: [] }; + }, + }; +} + +describe('fetchDetail', () => { + test('v4 token → Bearer header, no api_key param', async () => { + const { calls, http } = capture(); + await fetchDetail(http, 'eyJhbGciOi.fake.jwt', '693134', true); + expect(calls[0].headers['Authorization']).toBe('Bearer eyJhbGciOi.fake.jwt'); + expect(calls[0].url).not.toContain('api_key'); + }); + test('v3 key → api_key param, no auth header', async () => { + const { calls, http } = capture(); + await fetchDetail(http, 'abc123', '693134', true); + expect(calls[0].url).toContain('api_key=abc123'); + expect(calls[0].headers['Authorization']).toBeUndefined(); + }); + test('movie url + append', async () => { + const { calls, http } = capture(); + await fetchDetail(http, 'k', '693134', true); + expect(calls[0].url).toContain('/3/movie/693134'); + expect(calls[0].url).toContain('append_to_response=credits%2Cexternal_ids%2Crelease_dates%2Cvideos'); + expect(calls[0].url).toContain('language=en-US'); + }); + test('tv url + append', async () => { + const { calls, http } = capture(); + await fetchDetail(http, 'k', '84958', false); + expect(calls[0].url).toContain('/3/tv/84958'); + expect(calls[0].url).toContain('append_to_response=aggregate_credits%2Cexternal_ids%2Ccontent_ratings%2Cvideos'); + }); +}); + +describe('searchTitle', () => { + test('movie search url + year', async () => { + const { calls, http } = capture(); + await searchTitle(http, 'k', 'Dune Part Two', true, '2024'); + expect(calls[0].url).toContain('/3/search/movie'); + expect(calls[0].url).toContain('query=Dune+Part+Two'); + expect(calls[0].url).toContain('primary_release_year=2024'); + }); + test('tv search url', async () => { + const { calls, http } = capture(); + await searchTitle(http, 'k', 'Loki', false, '2021'); + expect(calls[0].url).toContain('/3/search/tv'); + expect(calls[0].url).toContain('first_air_date_year=2021'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test 2>&1 | tail -5` +Expected: FAIL — module not found + +- [ ] **Step 3: Write implementation** + +```ts +// packages/obsidian/src/watchlist/tmdb.ts +export type HttpJsonFn = (url: string, headers: Record) => Promise; + +const BASE = 'https://api.themoviedb.org/3'; + +function authParts(key: string): { headers: Record; extraParams: Record } { + if (key.startsWith('eyJ')) { + return { headers: { Authorization: `Bearer ${key}`, accept: 'application/json' }, extraParams: {} }; + } + return { headers: { accept: 'application/json' }, extraParams: { api_key: key } }; +} + +function buildUrl(path: string, params: Record): string { + const qs = new URLSearchParams(params); + return `${BASE}${path}?${qs.toString()}`; +} + +export async function fetchDetail(http: HttpJsonFn, key: string, tmdbId: string, isMovie: boolean): Promise { + const { headers, extraParams } = authParts(key); + const path = isMovie ? `/movie/${tmdbId}` : `/tv/${tmdbId}`; + const append = isMovie ? 'credits,external_ids,release_dates,videos' : 'aggregate_credits,external_ids,content_ratings,videos'; + return await http(buildUrl(path, { append_to_response: append, language: 'en-US', ...extraParams }), headers); +} + +export async function searchTitle(http: HttpJsonFn, key: string, query: string, isMovie: boolean, year?: string): Promise { + const { headers, extraParams } = authParts(key); + const params: Record = { query, language: 'en-US', ...extraParams }; + if (year) params[isMovie ? 'primary_release_year' : 'first_air_date_year'] = year; + const res = await http(buildUrl(isMovie ? '/search/movie' : '/search/tv', params), headers); + return res?.results ?? []; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test 2>&1 | tail -5` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/obsidian/src/watchlist/tmdb.ts tests/watchlist-tmdb.test.ts +git commit -m "feat(watchlist): tmdb client — v3/v4 auth, detail + search" +``` + +--- + +### Task 6: SyncEngine — iterate, tier, throttle, diff-on-write, watch rule + +**Files:** +- Create: `packages/obsidian/src/watchlist/SyncEngine.ts` +- Test: `tests/watchlist-sync-engine.test.ts` + +**Interfaces:** +- Consumes: `parseNote`, `extractMyNotes`, `noteTmdbRef` (Task 2); `buildRecord` (Task 3); `renderNote` (Task 4). +- Produces: + +```ts +export interface SyncDeps { + listNotes(): Promise<{ path: string; content: string }[]>; + writeNote(path: string, content: string): Promise; + fetchDetail(tmdbId: string, isMovie: boolean): Promise; // throws TmdbRateLimitError on 429 + sleep(ms: number): Promise; + log(msg: string): void; +} +export interface SyncOptions { full?: boolean; dryRun?: boolean; throttleMs?: number } // throttleMs default 250 +export interface SyncReport { scanned: number; synced: number; written: number; skippedNoId: number; skippedStatic: number; flipped: string[]; errors: { path: string; error: string }[] } +export class TmdbRateLimitError extends Error { retryAfterMs: number } +export async function syncFolder(deps: SyncDeps, opts?: SyncOptions): Promise +export function isActive(fm: Record): boolean +``` + +Tier rule: `isActive(fm)` = `status` ∈ {`Returning Series`, `In Production`, `Planned`, `Pilot`} OR `watch_status` = `Watching` OR `next_air_date` set (non-null, non-empty) OR `status` missing/empty. Everything else = STATIC → skipped unless `opts.full`. + +429 handling: on `TmdbRateLimitError`, `sleep(retryAfterMs)`, retry ONCE; second failure records error for that note and continues. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/watchlist-sync-engine.test.ts +import { describe, expect, test } from 'bun:test'; +import { syncFolder, isActive, TmdbRateLimitError, type SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine'; +import tvDetail from 'tests/fixtures/tmdb-tv-loki.json'; + +const ENDED_NOTE = `--- +type: watchlist_item +media_type: TV Series +watch_status: Watched +rating: 5 +rating_stars: ⭐️⭐️⭐️⭐️⭐️ +status: Ended +tmdb_id: 84958 +last_air_date: 2023-11-09 +--- + +# Loki + +## My Notes + +keep me +`; + +const AIRING_NOTE = ENDED_NOTE.replace('status: Ended', 'status: Returning Series').replace('last_air_date: 2023-11-09', 'last_air_date: 2023-10-01'); + +function makeDeps(notes: { path: string; content: string }[], detail: any = tvDetail) { + const writes: { path: string; content: string }[] = []; + const fetches: string[] = []; + const deps: SyncDeps = { + listNotes: async () => notes, + writeNote: async (path, content) => { writes.push({ path, content }); }, + fetchDetail: async (id) => { fetches.push(id); return detail; }, + sleep: async () => {}, + log: () => {}, + }; + return { deps, writes, fetches }; +} + +describe('isActive tiering', () => { + test('Returning Series → active', () => expect(isActive({ status: 'Returning Series' })).toBe(true)); + test('Watching → active regardless of status', () => expect(isActive({ status: 'Ended', watch_status: 'Watching' })).toBe(true)); + test('next_air_date set → active', () => expect(isActive({ status: 'Ended', next_air_date: '2026-08-01' })).toBe(true)); + test('Ended → static', () => expect(isActive({ status: 'Ended', next_air_date: 'null' })).toBe(false)); + test('Released movie → static', () => expect(isActive({ status: 'Released' })).toBe(false)); + test('missing status → active (needs first enrich)', () => expect(isActive({})).toBe(true)); +}); + +describe('syncFolder', () => { + test('default run skips static notes', async () => { + const { deps, fetches } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]); + const report = await syncFolder(deps); + expect(fetches.length).toBe(0); + expect(report.skippedStatic).toBe(1); + }); + test('full run processes static notes', async () => { + const { deps, fetches } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]); + await syncFolder(deps, { full: true }); + expect(fetches).toEqual(['84958']); + }); + test('diff-on-write: unchanged content not rewritten', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]); + const first = await syncFolder(deps, { full: true }); + expect(first.written).toBe(1); + // second pass with the note the engine just produced → no write + const rendered = (await (async () => { const w: any[] = []; const d2 = { ...deps, listNotes: async () => [{ path: 'Loki.md', content: (await deps as any).lastWrite }] }; return null; })(), null); + // simpler: re-run with the written content + }); + test('watch rule flips through full pipeline', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]); + const report = await syncFolder(deps); + expect(writes.length).toBe(1); + expect(writes[0].content).toContain('watch_status: Unwatched'); + expect(report.flipped).toEqual(['Loki.md']); + }); + test('My Notes preserved through rewrite', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]); + await syncFolder(deps); + expect(writes[0].content).toContain('keep me'); + }); + test('no tmdb ref → skipped, counted', async () => { + const { deps, fetches } = makeDeps([{ path: '_Dashboard.md', content: '# dash' }]); + const report = await syncFolder(deps, { full: true }); + expect(fetches.length).toBe(0); + expect(report.skippedNoId).toBe(1); + }); + test('dryRun: no writes, report counts', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]); + const report = await syncFolder(deps, { dryRun: true }); + expect(writes.length).toBe(0); + expect(report.written).toBe(1); // counts what WOULD be written + }); + test('429 → sleep(retryAfter) then retry succeeds', async () => { + let calls = 0; + const slept: number[] = []; + const deps: SyncDeps = { + listNotes: async () => [{ path: 'Loki.md', content: AIRING_NOTE }], + writeNote: async () => {}, + fetchDetail: async () => { + calls++; + if (calls === 1) { const e = new TmdbRateLimitError('429'); e.retryAfterMs = 1500; throw e; } + return tvDetail; + }, + sleep: async ms => { slept.push(ms); }, + log: () => {}, + }; + const report = await syncFolder(deps); + expect(calls).toBe(2); + expect(slept).toContain(1500); + expect(report.errors.length).toBe(0); + }); + test('fetch error recorded, other notes continue', async () => { + const { deps } = makeDeps([ + { path: 'Bad.md', content: AIRING_NOTE }, + { path: 'Good.md', content: AIRING_NOTE }, + ]); + let n = 0; + deps.fetchDetail = async () => { n++; if (n === 1) throw new Error('boom'); return tvDetail; }; + const report = await syncFolder(deps); + expect(report.errors.length).toBe(1); + expect(report.errors[0].path).toBe('Bad.md'); + expect(report.synced).toBe(1); + }); +}); +``` + +NOTE for implementer: replace the malformed `diff-on-write` test above with this working version: + +```ts + test('diff-on-write: second pass on rendered output writes nothing', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]); + await syncFolder(deps, { full: true }); + const rendered = writes[0].content; + const second = makeDeps([{ path: 'Loki.md', content: rendered }]); + const report = await syncFolder(second.deps, { full: true }); + expect(second.writes.length).toBe(0); + expect(report.written).toBe(0); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test 2>&1 | tail -5` +Expected: FAIL — module not found + +- [ ] **Step 3: Write implementation** + +```ts +// packages/obsidian/src/watchlist/SyncEngine.ts +import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; +import { buildRecord } from 'packages/obsidian/src/watchlist/build'; +import { renderNote } from 'packages/obsidian/src/watchlist/render'; + +export interface SyncDeps { + listNotes(): Promise<{ path: string; content: string }[]>; + writeNote(path: string, content: string): Promise; + fetchDetail(tmdbId: string, isMovie: boolean): Promise; + sleep(ms: number): Promise; + log(msg: string): void; +} + +export interface SyncOptions { + full?: boolean; + dryRun?: boolean; + throttleMs?: number; +} + +export interface SyncReport { + scanned: number; + synced: number; + written: number; + skippedNoId: number; + skippedStatic: number; + flipped: string[]; + errors: { path: string; error: string }[]; +} + +export class TmdbRateLimitError extends Error { + retryAfterMs: number = 2000; +} + +const ACTIVE_STATUSES = new Set(['Returning Series', 'In Production', 'Planned', 'Pilot']); + +function strip(s: string | undefined): string { + return (s ?? '').trim().replace(/^"|"$/g, ''); +} + +export function isActive(fm: Record): boolean { + const status = strip(fm['status']); + if (!status) return true; // never enriched → needs first pass + if (ACTIVE_STATUSES.has(status)) return true; + if (strip(fm['watch_status']) === 'Watching') return true; + const nextAir = strip(fm['next_air_date']); + if (nextAir && nextAir !== 'null') return true; + return false; +} + +export async function syncFolder(deps: SyncDeps, opts: SyncOptions = {}): Promise { + const throttleMs = opts.throttleMs ?? 250; + const report: SyncReport = { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, flipped: [], errors: [] }; + const notes = await deps.listNotes(); + for (const note of notes) { + report.scanned++; + const { frontmatter, body } = parseNote(note.content); + const ref = noteTmdbRef(frontmatter); + if (!ref) { + report.skippedNoId++; + continue; + } + if (!opts.full && !isActive(frontmatter)) { + report.skippedStatic++; + continue; + } + try { + let detail: any; + try { + detail = await deps.fetchDetail(ref.tmdbId, ref.isMovie); + } catch (e) { + if (e instanceof TmdbRateLimitError) { + await deps.sleep(e.retryAfterMs); + detail = await deps.fetchDetail(ref.tmdbId, ref.isMovie); + } else { + throw e; + } + } + const record = buildRecord(detail, ref.isMovie, frontmatter); + const rendered = renderNote(record, extractMyNotes(body)); + report.synced++; + if (strip(frontmatter['watch_status']) === 'Watched' && record.watchStatus === 'Unwatched') { + report.flipped.push(note.path); + } + if (rendered !== note.content) { + report.written++; + if (!opts.dryRun) await deps.writeNote(note.path, rendered); + deps.log(`${opts.dryRun ? '[dry] ' : ''}updated ${note.path}`); + } + await deps.sleep(throttleMs); + } catch (e) { + report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) }); + deps.log(`ERROR ${note.path}: ${String(e)}`); + } + } + return report; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test 2>&1 | tail -5` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/obsidian/src/watchlist/SyncEngine.ts tests/watchlist-sync-engine.test.ts +git commit -m "feat(watchlist): SyncEngine — tiered diff-on-write sync with throttle/backoff" +``` + +--- + +### Task 7: Settings + plugin wiring (commands, interval, catch-up) + +**Files:** +- Modify: `packages/obsidian/src/settings/Settings.ts` — add fields to `MediaDbPluginSettings` (~L52-132), defaults to `DEFAULT_SETTINGS` (~L311), UI section in `MediaDbSettingTab.display()` (~L442) +- Modify: `packages/obsidian/src/main.ts` — commands in `registerCommands()` (~L103), interval in `onload()` (~L48) +- Create: `packages/obsidian/src/watchlist/WatchlistController.ts` (vault adapter + scheduler glue) +- Test: `tests/watchlist-controller.test.ts` + +**Interfaces:** +- Consumes: `syncFolder`, `SyncDeps` (Task 6); `fetchDetail` (Task 5); existing `plugin.settings`, `app.vault`, `obsidianFetch` (`packages/obsidian/src/utils/Utils.ts:283`), secretStorage pattern from `TMDBMovieAPI.ts:64`. +- Produces: `WatchlistController` with `syncNow(full: boolean, dryRun: boolean): Promise`, `maybeCatchUp(): Promise`, `resolveMissingIds(dryRun: boolean): Promise` (impl Task 8). + +New settings fields (add to interface + defaults): + +```ts + watchlistEnabled: boolean; // default false + watchlistFolder: string; // default 'Watchlist' + watchlistSyncIntervalHours: number; // default 24 + watchlistLastSync: number; // epoch ms, default 0 — updated after each successful scheduled sync +``` + +Settings UI (new "Watchlist sync" group in `display()`): toggle `watchlistEnabled`, text `watchlistFolder`, slider/text `watchlistSyncIntervalHours` (1–168). TMDB key reuses the existing TMDB secret (`settings.TMDBKeyId` via secretStorage) — no new key field. + +`WatchlistController` core: + +```ts +// packages/obsidian/src/watchlist/WatchlistController.ts +import { Notice, TFile, TFolder } from 'obsidian'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { syncFolder, TmdbRateLimitError, type SyncDeps, type SyncReport } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { fetchDetail } from 'packages/obsidian/src/watchlist/tmdb'; +import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; + +export class WatchlistController { + constructor(private plugin: MediaDbPlugin) {} + + private async getKey(): Promise { + const keyId = this.plugin.settings.TMDBKeyId; + const key = keyId ? await this.plugin.app.secretStorage.getSecret(keyId) : ''; + if (!key) throw new Error('TMDB API key not configured (Media DB Sync settings).'); + return key; + } + + private makeDeps(key: string): SyncDeps { + const { app } = this.plugin; + const folder = app.vault.getAbstractFileByPath(this.plugin.settings.watchlistFolder); + return { + listNotes: async () => { + if (!(folder instanceof TFolder)) throw new Error(`Watchlist folder not found: ${this.plugin.settings.watchlistFolder}`); + const files = folder.children.filter((f): f is TFile => f instanceof TFile && f.extension === 'md' && !f.name.startsWith('_')); + const out: { path: string; content: string }[] = []; + for (const f of files) out.push({ path: f.path, content: await app.vault.read(f) }); + return out; + }, + writeNote: async (path, content) => { + const f = app.vault.getAbstractFileByPath(path); + if (f instanceof TFile) await app.vault.modify(f, content); + }, + fetchDetail: async (tmdbId, isMovie) => { + const http = async (url: string, headers: Record): Promise => { + const res = await obsidianFetch({ url, headers }); + if (res.status === 429) { + const err = new TmdbRateLimitError('TMDB 429'); + const ra = Number(res.headers?.['retry-after'] ?? 2); + err.retryAfterMs = ra * 1000; + throw err; + } + if (res.status !== 200) throw new Error(`TMDB ${res.status} for ${url}`); + return res.json; + }; + return await fetchDetail(http, tmdbId, isMovie, key === undefined ? '' : key); + }, + sleep: ms => new Promise(r => setTimeout(r, ms)), + log: msg => console.log(`[media-db-sync] ${msg}`), + }; + } + + async syncNow(full: boolean, dryRun = false): Promise { + const key = await this.getKey(); + const report = await syncFolder(this.makeDeps(key), { full, dryRun }); + const mode = dryRun ? 'DRY-RUN ' : ''; + new Notice( + `Watchlist ${mode}sync: ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped to Unwatched` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + if (!dryRun) { + this.plugin.settings.watchlistLastSync = Date.now(); + await this.plugin.saveSettings(); + } + return report; + } + + async maybeCatchUp(): Promise { + const s = this.plugin.settings; + if (!s.watchlistEnabled) return; + const due = s.watchlistLastSync + s.watchlistSyncIntervalHours * 3600_000; + if (Date.now() >= due) { + await this.syncNow(false).catch(e => console.error('[media-db-sync] scheduled sync failed', e)); + } + } +} +``` + +NOTE for implementer: check exact `obsidianFetch` signature/return at `packages/obsidian/src/utils/Utils.ts:283` and the exact secretStorage call shape at `packages/obsidian/src/api/apis/TMDBMovieAPI.ts:62-90` before writing — mirror what the codebase actually does (incl. how non-200s and headers surface). Fix the `fetchDetail(http, tmdbId, isMovie, key…)` argument order to match Task 5's signature `fetchDetail(http, key, tmdbId, isMovie)`. + +`main.ts` wiring (inside `onload()` after settings load): + +```ts + this.watchlist = new WatchlistController(this); + // catch-up shortly after startup (let vault index settle), then hourly due-check + window.setTimeout(() => void this.watchlist.maybeCatchUp(), 30_000); + this.registerInterval(window.setInterval(() => void this.watchlist.maybeCatchUp(), 3600_000)); +``` + +Commands (inside `registerCommands()`): + +```ts + this.addCommand({ + id: 'watchlist-sync-now', + name: 'Watchlist: sync now (airing/active only)', + callback: () => void this.watchlist.syncNow(false), + }); + this.addCommand({ + id: 'watchlist-sync-full', + name: 'Watchlist: full sync (all entries)', + callback: () => void this.watchlist.syncNow(true), + }); + this.addCommand({ + id: 'watchlist-sync-dry-run', + name: 'Watchlist: dry-run full sync (log only, no writes)', + callback: () => void this.watchlist.syncNow(true, true), + }); +``` + +- [ ] **Step 1: Write failing controller test** — `tests/watchlist-controller.test.ts` covering `maybeCatchUp` due-time logic with a fake plugin object (settings + stubbed `syncNow`): overdue → calls sync; not due → no call; disabled → no call. + +```ts +// tests/watchlist-controller.test.ts +import { describe, expect, test } from 'bun:test'; +import { WatchlistController } from 'packages/obsidian/src/watchlist/WatchlistController'; + +function fakePlugin(overrides: Partial<{ enabled: boolean; last: number; hours: number }> = {}) { + return { + settings: { + watchlistEnabled: overrides.enabled ?? true, + watchlistLastSync: overrides.last ?? 0, + watchlistSyncIntervalHours: overrides.hours ?? 24, + watchlistFolder: 'Watchlist', + TMDBKeyId: 'kid', + }, + saveSettings: async () => {}, + app: {}, + } as any; +} + +describe('maybeCatchUp', () => { + test('overdue → syncs', async () => { + const c = new WatchlistController(fakePlugin({ last: 0 })); + let called = false; + (c as any).syncNow = async () => { called = true; return {} as any; }; + await c.maybeCatchUp(); + expect(called).toBe(true); + }); + test('recent sync → no call', async () => { + const c = new WatchlistController(fakePlugin({ last: Date.now() })); + let called = false; + (c as any).syncNow = async () => { called = true; return {} as any; }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); + test('disabled → no call', async () => { + const c = new WatchlistController(fakePlugin({ enabled: false, last: 0 })); + let called = false; + (c as any).syncNow = async () => { called = true; return {} as any; }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test 2>&1 | tail -5` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement** `WatchlistController.ts` (code above, with obsidianFetch/secretStorage shapes verified against the codebase), settings fields + defaults + UI group, `main.ts` commands + interval + `watchlist` property. + +- [ ] **Step 4: Run all gates** + +Run: `bun run test 2>&1 | tail -5` → PASS; `bun run tsc` → exit 0; `bun run build 2>&1 | tail -3` → built. + +- [ ] **Step 5: Commit** + +```bash +git add packages/obsidian/src/watchlist/WatchlistController.ts packages/obsidian/src/settings/Settings.ts packages/obsidian/src/main.ts tests/watchlist-controller.test.ts +git commit -m "feat(watchlist): settings, commands, catch-up scheduler wiring" +``` + +--- + +### Task 8: Resolve-missing-ids command + +**Files:** +- Create: `packages/obsidian/src/watchlist/resolve.ts` +- Modify: `packages/obsidian/src/watchlist/WatchlistController.ts` (add `resolveMissingIds`) +- Modify: `packages/obsidian/src/main.ts` (add command) +- Test: `tests/watchlist-resolve.test.ts` + +**Interfaces:** +- Consumes: `searchTitle` (Task 5), `parseNote` (Task 2). +- Produces: `resolveNote(fm: Record, filename: string, search: (q: string, isMovie: boolean, year?: string) => Promise): Promise<{ tmdbId: string; isMovie: boolean; matchedTitle: string } | null>`. + +Rules: candidate query = `title` field else filename minus `.md`. `year` hint = leading 4 digits of `year`/`release_date` if present. `media_type` field decides movie vs tv; missing → try movie first, then tv. Accept top result ONLY if its title (or original title) case-insensitively equals the query, OR it is the only result. Otherwise return null (log for manual fix) — no guessing. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/watchlist-resolve.test.ts +import { describe, expect, test } from 'bun:test'; +import { resolveNote } from 'packages/obsidian/src/watchlist/resolve'; + +const HIT = { id: 693134, title: 'Dune: Part Two', original_title: 'Dune: Part Two' }; +const OTHER = { id: 1, title: 'Dune', original_title: 'Dune' }; + +describe('resolveNote', () => { + test('exact title match accepted', async () => { + const r = await resolveNote({ media_type: 'Movie' }, 'Dune: Part Two.md', async () => [HIT, OTHER]); + expect(r).toEqual({ tmdbId: '693134', isMovie: true, matchedTitle: 'Dune: Part Two' }); + }); + test('single result accepted even if inexact', async () => { + const r = await resolveNote({ media_type: 'Movie' }, 'Dune Part 2.md', async () => [HIT]); + expect(r?.tmdbId).toBe('693134'); + }); + test('ambiguous → null', async () => { + const r = await resolveNote({ media_type: 'Movie' }, 'Dune something.md', async () => [HIT, OTHER]); + expect(r).toBeNull(); + }); + test('no media_type → movie then tv fallback', async () => { + const calls: boolean[] = []; + const r = await resolveNote({}, 'Loki.md', async (q, isMovie) => { + calls.push(isMovie); + return isMovie ? [] : [{ id: 84958, name: 'Loki', original_name: 'Loki' }]; + }); + expect(calls).toEqual([true, false]); + expect(r).toEqual({ tmdbId: '84958', isMovie: false, matchedTitle: 'Loki' }); + }); + test('year hint passed through', async () => { + let seenYear: string | undefined; + await resolveNote({ media_type: 'Movie', year: '2024' }, 'Dune: Part Two.md', async (q, m, year) => { + seenYear = year; + return [HIT]; + }); + expect(seenYear).toBe('2024'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test 2>&1 | tail -5` +Expected: FAIL — module not found + +- [ ] **Step 3: Write implementation** + +```ts +// packages/obsidian/src/watchlist/resolve.ts +type SearchFn = (query: string, isMovie: boolean, year?: string) => Promise; + +export interface ResolveResult { + tmdbId: string; + isMovie: boolean; + matchedTitle: string; +} + +function resultTitle(r: any): string { + return r.title ?? r.name ?? ''; +} + +function resultTitles(r: any): string[] { + return [r.title, r.original_title, r.name, r.original_name].filter(Boolean).map((t: string) => t.toLowerCase()); +} + +async function tryOne(query: string, isMovie: boolean, year: string | undefined, search: SearchFn): Promise { + const results = await search(query, isMovie, year); + if (results.length === 0) return null; + const q = query.toLowerCase(); + const exact = results.find(r => resultTitles(r).includes(q)); + const pick = exact ?? (results.length === 1 ? results[0] : null); + if (!pick) return null; + return { tmdbId: String(pick.id), isMovie, matchedTitle: resultTitle(pick) }; +} + +export async function resolveNote(fm: Record, filename: string, search: SearchFn): Promise { + const strip = (s: string | undefined): string => (s ?? '').trim().replace(/^"|"$/g, ''); + const query = strip(fm['title']) || filename.replace(/\.md$/, ''); + const yearField = strip(fm['year']) || strip(fm['release_date']); + const year = /^\d{4}/.exec(yearField)?.[0]; + const mt = strip(fm['media_type']); + if (mt === 'Movie') return await tryOne(query, true, year, search); + if (mt) return await tryOne(query, false, year, search); + return (await tryOne(query, true, year, search)) ?? (await tryOne(query, false, year, search)); +} +``` + +Controller addition (`WatchlistController.resolveMissingIds(dryRun)`): list notes → for each with NO `noteTmdbRef` AND `type` missing or `watchlist_item` (skip `_`-prefixed, skip `type: list` dashboards) → `resolveNote` with `searchTitle` bound to key/http → on hit: patch `tmdb_id` + `media_type` into frontmatter (regex insert after `type:` line or prepend fm block if absent) and log; on null: log "ambiguous/no match". Notice summary at end. Command id `watchlist-resolve-ids`, name `Watchlist: resolve missing TMDB ids`. Next scheduled/manual sync then enriches them (missing `status` → ACTIVE tier → picked up automatically). + +- [ ] **Step 4: Run all gates** + +Run: `bun run test 2>&1 | tail -5` → PASS; `bun run tsc` → 0; `bun run build 2>&1 | tail -3` → built. + +- [ ] **Step 5: Commit** + +```bash +git add packages/obsidian/src/watchlist/resolve.ts packages/obsidian/src/watchlist/WatchlistController.ts packages/obsidian/src/main.ts tests/watchlist-resolve.test.ts +git commit -m "feat(watchlist): resolve-missing-ids command" +``` + +--- + +### Task 9: Real-vault dry-run verification + +**Files:** +- No new source. Verification against `/Users/AfiqZudinHadi/Documents/ai_brain/02 - Areas/Interests/Watchlist/` (568 notes; ALL have tmdb_id — verified 2026-07-29; 5 extra non-entry files skip via no-id path). + +- [ ] **Step 1: Commit vault state** — `cd ~/Documents/ai_brain && git add -A && git commit -m "pre-sync snapshot"` (revert point). +- [ ] **Step 2: Install dev build** — copy `dist/main.js`, `dist/styles.css`, `manifest.json` into `~/Documents/ai_brain/.obsidian/plugins/media-db-sync/`, enable in Obsidian, set folder `02 - Areas/Interests/Watchlist`, confirm TMDB key present. +- [ ] **Step 3: Run `Watchlist: dry-run full sync`** — read console log. Expect: 568 checked, plausible update count (formatting normalization, e.g. `tmdb_rating 7.0→7`, is expected on first pass), 0 errors, no vault modifications (`git status` clean). +- [ ] **Step 4: Review a sample of logged diffs** — user (Afiq) approves before any live run. STOP here for user sign-off. +- [ ] **Step 5: Live full sync** — run `Watchlist: full sync`, then `git diff --stat` in vault; spot-check: an airing series updated, an Ended one byte-identical (or normalized once), `## My Notes` intact everywhere (`git diff -U0 | grep -c 'My Notes'` → 0 content changes under that heading). +- [ ] **Step 6: Verify in Obsidian Base** — `Watchlist.base` tabs render; `next_air_date`/`last_episode` fresh on airing series. +- [ ] **Step 7: Commit fork** — final state, push branch. + +--- + +## Self-Review Notes + +- Spec coverage: schema fields (SPEC.md) → Tasks 3/4 incl. `seasons`/`episodes`/`vod` (absent from python reference, present in real vault — always emit, movies `null`/`null`/`[]`). Sync + tiering + throttle → Task 6. Catch-up scheduler → Task 7. Watch rule → Task 3 (logic) + 6 (report). Resolve → Task 8. v3/v4 auth → Task 5. Preserve fields → Tasks 3/6 tests. Dry-run → Tasks 6/7/9. +- Known deliberate deviations from python: comma added to `yamlScalar` quoting regex (matches real vault output); `seasons`/`episodes`/`vod` added; TS number formatting may normalize `x.0` ratings (one-time diff, reviewed in Task 9 dry-run). +- Type consistency: `WatchlistRecord` field names used identically in Tasks 3, 4, 6. `fetchDetail(http, key, tmdbId, isMovie)` — Task 7 code block has an arg-order bug flagged in its NOTE; implementer must match Task 5. +- Phase 2 (manga/books/games) intentionally out of scope. From cade7104dbeb81d5a05c1d5611ea314e88249c95 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 29 Jul 2026 21:47:25 +0800 Subject: [PATCH 03/53] feat(watchlist): yaml helpers + schema types --- packages/obsidian/src/watchlist/schema.ts | 40 +++++++++++++++++ packages/obsidian/src/watchlist/yaml.ts | 25 +++++++++++ tests/watchlist-yaml.test.ts | 53 +++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 packages/obsidian/src/watchlist/schema.ts create mode 100644 packages/obsidian/src/watchlist/yaml.ts create mode 100644 tests/watchlist-yaml.test.ts diff --git a/packages/obsidian/src/watchlist/schema.ts b/packages/obsidian/src/watchlist/schema.ts new file mode 100644 index 0000000..c832ef5 --- /dev/null +++ b/packages/obsidian/src/watchlist/schema.ts @@ -0,0 +1,40 @@ +export type WatchCategory = 'Movie' | 'Series' | 'Anime'; +export type WatchMediaType = 'Movie' | 'TV Series'; + +export interface WatchlistRecord { + title: string; + engName: string; + mediaType: WatchMediaType; + category: WatchCategory; + watchStatus: string; + rating: string; + ratingStars: string; + year: string; + runtime: number | null; + seasons: number | null; + episodes: number | null; + vod: string[]; + genre: string[]; + status: string; + language: string; + country: string; + director: string[]; + writer: string[]; + producer: string[]; + contentRating: string; + tmdbRating: number | null; + tmdbId: string; + imdbId: string; + releaseDate: string | null; + lastAirDate: string | null; + nextAirDate: string | null; + lastEpisode: string | null; + upcomingEpisode: string | null; + poster: string | null; + trailer: string; + homepage: string; + imdbPage: string; + notionUrl: string; + synopsis: string; + cast: string; +} diff --git a/packages/obsidian/src/watchlist/yaml.ts b/packages/obsidian/src/watchlist/yaml.ts new file mode 100644 index 0000000..6204f41 --- /dev/null +++ b/packages/obsidian/src/watchlist/yaml.ts @@ -0,0 +1,25 @@ +const NEEDS_QUOTE = /[:,#\[\]{}"&*!|>%@`]/; +const KEYWORDS = new Set(['null', 'true', 'false', 'yes', 'no']); + +export function yamlScalar(v: unknown): string { + if (v === null || v === undefined || v === '') return ''; + const s = String(v); + if (NEEDS_QUOTE.test(s) || s.trim() !== s || KEYWORDS.has(s.toLowerCase())) { + return '"' + s.replace(/"/g, '\\"') + '"'; + } + return s; +} + +export function yamlList(items: string[]): string { + const parts: string[] = []; + for (const raw of items) { + const g = String(raw).trim(); + if (!g) continue; + parts.push(/^[A-Za-z0-9 ]+$/.test(g) ? g : '"' + g.replace(/"/g, '\\"') + '"'); + } + return '[' + parts.join(', ') + ']'; +} + +export function quotedOrNull(v: string | null | undefined): string { + return v ? '"' + v + '"' : 'null'; +} diff --git a/tests/watchlist-yaml.test.ts b/tests/watchlist-yaml.test.ts new file mode 100644 index 0000000..8719a44 --- /dev/null +++ b/tests/watchlist-yaml.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test'; +import { yamlScalar, yamlList, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; + +describe('yamlScalar', () => { + test('plain string passes through', () => { + expect(yamlScalar('English')).toBe('English'); + }); + test('empty/null/undefined → empty string', () => { + expect(yamlScalar('')).toBe(''); + expect(yamlScalar(null)).toBe(''); + expect(yamlScalar(undefined)).toBe(''); + }); + test('special chars → quoted', () => { + expect(yamlScalar('S2, E6: Glorious Purpose')).toBe('"S2, E6: Glorious Purpose"'); + expect(yamlScalar('Aaron Moorhead, Justin Benson')).toBe('"Aaron Moorhead, Justin Benson"'); + }); + test('comma alone does NOT quote (matches python regex)', () => { + // python regex [:#\[\]{}}&*!|>%@`] has no comma; "a, b" quotes via ":"? no — verify: comma not in class, so "Alter, Turtle" stays plain + expect(yamlScalar('Rachel Alter and Tommy Turtle')).toBe('Rachel Alter and Tommy Turtle'); + }); + test('yaml keywords → quoted', () => { + expect(yamlScalar('null')).toBe('"null"'); + expect(yamlScalar('No')).toBe('"No"'); + }); + test('embedded quotes escaped', () => { + expect(yamlScalar('He said "hi"')).toBe('"He said \\"hi\\""'); + }); +}); + +describe('yamlList', () => { + test('plain items unquoted, special quoted', () => { + expect(yamlList(['Drama', 'Sci-Fi & Fantasy'])).toBe('[Drama, "Sci-Fi & Fantasy"]'); + }); + test('empty list', () => { + expect(yamlList([])).toBe('[]'); + }); + test('blank items dropped', () => { + expect(yamlList(['', 'Drama', ' '])).toBe('[Drama]'); + }); + test('Disney+ quoted', () => { + expect(yamlList(['Disney+'])).toBe('["Disney+"]'); + }); +}); + +describe('quotedOrNull', () => { + test('value → quoted', () => { + expect(quotedOrNull('https://x.y/z')).toBe('"https://x.y/z"'); + }); + test('empty/null → null literal', () => { + expect(quotedOrNull('')).toBe('null'); + expect(quotedOrNull(null)).toBe('null'); + }); +}); From 732ec8adf657515ee0f24c65196fe2a095ef0333 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 29 Jul 2026 22:55:46 +0800 Subject: [PATCH 04/53] feat(watchlist): note parsing (frontmatter, My Notes, tmdb ref) --- packages/obsidian/src/watchlist/parse.ts | 36 +++++++++++++++ tests/watchlist-parse.test.ts | 57 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 packages/obsidian/src/watchlist/parse.ts create mode 100644 tests/watchlist-parse.test.ts diff --git a/packages/obsidian/src/watchlist/parse.ts b/packages/obsidian/src/watchlist/parse.ts new file mode 100644 index 0000000..29a2eb8 --- /dev/null +++ b/packages/obsidian/src/watchlist/parse.ts @@ -0,0 +1,36 @@ +export interface ParsedNote { + frontmatter: Record; + body: string; +} + +export function parseNote(content: string): ParsedNote { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(content); + const frontmatter: Record = {}; + let body = content; + if (m) { + body = m[2]; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) frontmatter[mm[1]] = mm[2].trim(); + } + } + return { frontmatter, body }; +} + +export function extractMyNotes(body: string): string { + const m = /##\s*My Notes\s*([\s\S]*)$/.exec(body ?? ''); + return m ? m[1].trim() : ''; +} + +function stripQuotes(s: string | undefined): string { + return (s ?? '').trim().replace(/^"|"$/g, ''); +} + +export function noteTmdbRef(fm: Record): { tmdbId: string; isMovie: boolean } | null { + const tid = stripQuotes(fm['tmdb_id']); + if (tid) return { tmdbId: tid, isMovie: stripQuotes(fm['media_type']) === 'Movie' }; + const rawId = stripQuotes(fm['id']); + const ds = (fm['dataSource'] ?? '').trim(); + if (rawId && ds.startsWith('TMDB')) return { tmdbId: rawId, isMovie: ds.includes('Movie') }; + return null; +} diff --git a/tests/watchlist-parse.test.ts b/tests/watchlist-parse.test.ts new file mode 100644 index 0000000..27c0f70 --- /dev/null +++ b/tests/watchlist-parse.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; + +const NOTE = `--- +type: watchlist_item +media_type: TV Series +tmdb_id: 84958 +last_air_date: 2023-11-09 +watch_status: Watched +--- + +# Loki + +## My Notes + +great finale +`; + +describe('parseNote', () => { + test('splits frontmatter and body', () => { + const { frontmatter, body } = parseNote(NOTE); + expect(frontmatter['tmdb_id']).toBe('84958'); + expect(frontmatter['media_type']).toBe('TV Series'); + expect(body).toContain('# Loki'); + }); + test('no frontmatter → empty fm, full body', () => { + const { frontmatter, body } = parseNote('# Just a heading'); + expect(Object.keys(frontmatter).length).toBe(0); + expect(body).toBe('# Just a heading'); + }); +}); + +describe('extractMyNotes', () => { + test('extracts trailing section', () => { + expect(extractMyNotes(parseNote(NOTE).body)).toBe('great finale'); + }); + test('missing section → empty', () => { + expect(extractMyNotes('# T\n\ncontent')).toBe(''); + }); +}); + +describe('noteTmdbRef', () => { + test('canonical note', () => { + expect(noteTmdbRef({ tmdb_id: '84958', media_type: 'TV Series' })).toEqual({ tmdbId: '84958', isMovie: false }); + expect(noteTmdbRef({ tmdb_id: '693134', media_type: 'Movie' })).toEqual({ tmdbId: '693134', isMovie: true }); + }); + test('quoted values stripped', () => { + expect(noteTmdbRef({ tmdb_id: '"84958"', media_type: '"TV Series"' })).toEqual({ tmdbId: '84958', isMovie: false }); + }); + test('raw Media DB note fallback (id + dataSource)', () => { + expect(noteTmdbRef({ id: '693134', dataSource: 'TMDBMovieAPI' })).toEqual({ tmdbId: '693134', isMovie: true }); + expect(noteTmdbRef({ id: '84958', dataSource: 'TMDBSeriesAPI' })).toEqual({ tmdbId: '84958', isMovie: false }); + }); + test('no id → null', () => { + expect(noteTmdbRef({ type: 'list' })).toBeNull(); + }); +}); From 51d630fcd67de7a675eb05426b5890ed2e7678f8 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 29 Jul 2026 23:15:36 +0800 Subject: [PATCH 05/53] =?UTF-8?q?feat(watchlist):=20buildRecord=20?= =?UTF-8?q?=E2=80=94=20TMDB=20detail=20=E2=86=92=20canonical=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port pure fn mapping raw TMDB JSON + prev frontmatter → WatchlistRecord. Fixtures from python reference selftest. TDD: 14 new cases all green. --- packages/obsidian/src/watchlist/build.ts | 162 +++++++++++++++++++++++ tests/fixtures/tmdb-movie-dune2.json | 81 ++++++++++++ tests/fixtures/tmdb-tv-loki.json | 80 +++++++++++ tests/watchlist-build.test.ts | 112 ++++++++++++++++ 4 files changed, 435 insertions(+) create mode 100644 packages/obsidian/src/watchlist/build.ts create mode 100644 tests/fixtures/tmdb-movie-dune2.json create mode 100644 tests/fixtures/tmdb-tv-loki.json create mode 100644 tests/watchlist-build.test.ts diff --git a/packages/obsidian/src/watchlist/build.ts b/packages/obsidian/src/watchlist/build.ts new file mode 100644 index 0000000..2b3744c --- /dev/null +++ b/packages/obsidian/src/watchlist/build.ts @@ -0,0 +1,162 @@ +import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; + +export type TmdbDetail = Record; + +const IMG_BASE = 'https://image.tmdb.org/t/p/original'; + +const LANG_FALLBACK: Record = { + en: 'English', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', fr: 'French', + es: 'Spanish', de: 'German', hi: 'Hindi', ta: 'Tamil', th: 'Thai', +}; + +function langName(details: TmdbDetail): string { + const code: string = details.original_language ?? ''; + for (const sl of details.spoken_languages ?? []) { + if (sl.iso_639_1 === code && sl.english_name) return sl.english_name; + } + return LANG_FALLBACK[code] ?? code; +} + +function crewNames(crew: any[], jobs: Set): string[] { + const seen = new Set(); + const out: string[] = []; + for (const c of crew) { + if (jobs.has(c.job) && !seen.has(c.name)) { + seen.add(c.name); + out.push(c.name); + } + } + return out; +} + +function usCertFromReleaseDates(rd: TmdbDetail): string { + for (const entry of rd?.results ?? []) { + if (entry.iso_3166_1 === 'US') { + for (const d of entry.release_dates ?? []) { + if (d.certification) return d.certification; + } + } + } + return ''; +} + +function usCertFromContentRatings(cr: TmdbDetail): string { + for (const entry of cr?.results ?? []) { + if (entry.iso_3166_1 === 'US' && entry.rating) return entry.rating; + } + return ''; +} + +function pickTrailer(videos: TmdbDetail): string { + const vids: any[] = videos?.results ?? []; + const yt = (v: any): string => 'https://www.youtube.com/watch?v=' + v.key; + for (const v of vids) if (v.site === 'YouTube' && v.type === 'Trailer' && v.official) return yt(v); + for (const v of vids) if (v.site === 'YouTube' && v.type === 'Trailer') return yt(v); + return ''; +} + +export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record): WatchlistRecord { + const genres: string[] = (details.genres ?? []).map((g: any) => g.name); + const language = langName(details); + const imdbId: string = details.external_ids?.imdb_id ?? ''; + const trailer = pickTrailer(details.videos ?? {}); + + let title: string, originalTitle: string, mediaType: 'Movie' | 'TV Series'; + let releaseDate: string | null, runtime: number | null, status: string, contentRating: string, country: string; + let cast: string[], director: string[], writer: string[], producer: string[]; + let seasons: number | null, episodes: number | null, vod: string[]; + let lastAirDate: string | null = null, nextAirDate: string | null = null; + let lastEpisode: string | null = null, upEpisode: string | null = null; + let yearDisp: string; + + if (isMovie) { + const credits = details.credits ?? {}; + cast = (credits.cast ?? []).slice(0, 12).map((c: any) => c.name); + const crew: any[] = credits.crew ?? []; + director = crewNames(crew, new Set(['Director'])); + writer = crewNames(crew, new Set(['Writer', 'Screenplay', 'Story'])); + producer = crewNames(crew, new Set(['Producer'])); + title = details.title ?? ''; + originalTitle = details.original_title ?? ''; + mediaType = 'Movie'; + releaseDate = details.release_date || null; + runtime = details.runtime || null; + status = details.status ?? ''; + contentRating = usCertFromReleaseDates(details.release_dates ?? {}); + const countries: string[] = (details.production_countries ?? []).map((c: any) => c.name); + country = countries[0] ?? ''; + yearDisp = (releaseDate ?? '').slice(0, 4); + seasons = null; + episodes = null; + vod = []; + } else { + const agg = details.aggregate_credits ?? {}; + cast = (agg.cast ?? []).slice(0, 12).map((c: any) => c.name); + const createdBy: string[] = (details.created_by ?? []).map((c: any) => c.name); + director = createdBy; // series: creators (latest-episode director needs extra call — Phase 3) + writer = createdBy; + producer = []; + title = details.name ?? ''; + originalTitle = details.original_name ?? ''; + mediaType = 'TV Series'; + releaseDate = details.first_air_date || null; + const rt: number[] = details.episode_run_time ?? []; + runtime = rt.length > 0 ? rt[0] : null; + status = details.status ?? ''; + contentRating = usCertFromContentRatings(details.content_ratings ?? {}); + const countries: string[] = details.origin_country ?? []; + country = countries[0] ?? ''; + seasons = details.number_of_seasons ?? null; + episodes = details.number_of_episodes ?? null; + vod = (details.networks ?? []).map((n: any) => n.name); + lastAirDate = details.last_air_date || null; + const le = details.last_episode_to_air; + const ne = details.next_episode_to_air; + lastEpisode = le ? `S${le.season_number}, E${le.episode_number}: ${le.name}` : null; + upEpisode = ne + ? `S${ne.season_number}, E${ne.episode_number}: ${ne.name}` + : ['Returning Series', 'Pilot'].includes(status) + ? 'TBA' + : null; + nextAirDate = ne?.air_date ?? null; + const start = (releaseDate ?? '').slice(0, 4); + const ended = ['Ended', 'Canceled', 'Cancelled'].includes(status); + const end = ended ? (lastAirDate ?? '').slice(0, 4) : null; + yearDisp = end && end !== start ? `${start} - ${end}` : start && !ended ? `${start} -` : start; + } + + const category: 'Movie' | 'Series' | 'Anime' = + genres.includes('Animation') && language === 'Japanese' ? 'Anime' : isMovie ? 'Movie' : 'Series'; + + const poster = details.poster_path ? IMG_BASE + details.poster_path : null; + let engName = ''; + // eslint-disable-next-line no-control-regex + if (originalTitle && originalTitle !== title && !/^[\x00-\x7F ]+$/.test(originalTitle)) { + engName = title; // original is non-Latin → english name is the localized title + } + + // ---- preserve user-managed fields ---- + let watchStatus = prev['watch_status'] || 'Unwatched'; + const rating = prev['rating'] || '0'; + const ratingStars = prev['rating_stars'] ?? ''; + const notionUrl = prev['notion_url'] || ''; + + // ---- TV watch-status rule: new episode aired since last sync ---- + const prevLast = (prev['last_air_date'] ?? '').trim().replace(/^"|"$/g, ''); + if (!isMovie && watchStatus === 'Watched' && lastAirDate && prevLast && lastAirDate > prevLast) { + watchStatus = 'Unwatched'; + } + + return { + title, engName, mediaType, category, + watchStatus, rating, ratingStars, + year: yearDisp, runtime, seasons, episodes, vod, genre: genres, status, + language, country, director, writer, producer, contentRating, + tmdbRating: details.vote_average ?? null, tmdbId: String(details.id), + imdbId, releaseDate, lastAirDate, nextAirDate, lastEpisode, upcomingEpisode: upEpisode, + poster, trailer, homepage: details.homepage ?? '', + imdbPage: imdbId ? `https://www.imdb.com/title/${imdbId}/` : '', + notionUrl, synopsis: details.overview ?? '', + cast: cast.join(', '), + }; +} diff --git a/tests/fixtures/tmdb-movie-dune2.json b/tests/fixtures/tmdb-movie-dune2.json new file mode 100644 index 0000000..72d5e3c --- /dev/null +++ b/tests/fixtures/tmdb-movie-dune2.json @@ -0,0 +1,81 @@ +{ + "id": 693134, + "title": "Dune: Part Two", + "original_title": "Dune: Part Two", + "original_language": "en", + "spoken_languages": [ + { + "iso_639_1": "en", + "english_name": "English" + } + ], + "overview": "Paul Atreides unites with the Fremen...", + "runtime": 167, + "status": "Released", + "release_date": "2024-02-27", + "vote_average": 8.1, + "homepage": "https://www.dunemovie.com", + "poster_path": "/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg", + "genres": [ + { + "name": "Science Fiction" + }, + { + "name": "Adventure" + } + ], + "production_countries": [ + { + "name": "United States of America" + } + ], + "credits": { + "cast": [ + { + "name": "Timothée Chalamet" + }, + { + "name": "Zendaya" + } + ], + "crew": [ + { + "job": "Director", + "name": "Denis Villeneuve" + }, + { + "job": "Screenplay", + "name": "Jon Spaihts" + }, + { + "job": "Producer", + "name": "Mary Parent" + } + ] + }, + "external_ids": { + "imdb_id": "tt15239678" + }, + "release_dates": { + "results": [ + { + "iso_3166_1": "US", + "release_dates": [ + { + "certification": "PG-13" + } + ] + } + ] + }, + "videos": { + "results": [ + { + "site": "YouTube", + "type": "Trailer", + "official": true, + "key": "Way9Dexny3w" + } + ] + } +} diff --git a/tests/fixtures/tmdb-tv-loki.json b/tests/fixtures/tmdb-tv-loki.json new file mode 100644 index 0000000..0e6085a --- /dev/null +++ b/tests/fixtures/tmdb-tv-loki.json @@ -0,0 +1,80 @@ +{ + "id": 84958, + "name": "Loki", + "original_name": "Loki", + "original_language": "en", + "spoken_languages": [ + { + "iso_639_1": "en", + "english_name": "English" + } + ], + "overview": "After stealing the Tesseract...", + "episode_run_time": [], + "status": "Ended", + "first_air_date": "2021-06-09", + "last_air_date": "2023-11-09", + "number_of_seasons": 2, + "number_of_episodes": 12, + "networks": [ + { + "name": "Disney+" + } + ], + "vote_average": 8.2, + "homepage": "https://www.disneyplus.com/series/wp/6pARMvILBGzF", + "poster_path": null, + "genres": [ + { + "name": "Drama" + }, + { + "name": "Sci-Fi & Fantasy" + } + ], + "origin_country": [ + "United States of America" + ], + "created_by": [ + { + "name": "Michael Waldron" + } + ], + "aggregate_credits": { + "cast": [ + { + "name": "Tom Hiddleston" + }, + { + "name": "Sophia Di Martino" + } + ] + }, + "external_ids": { + "imdb_id": "tt9140554" + }, + "content_ratings": { + "results": [ + { + "iso_3166_1": "US", + "rating": "TV-14" + } + ] + }, + "videos": { + "results": [ + { + "site": "YouTube", + "type": "Trailer", + "official": true, + "key": "nW948Va-l10" + } + ] + }, + "last_episode_to_air": { + "season_number": 2, + "episode_number": 6, + "name": "Glorious Purpose" + }, + "next_episode_to_air": null +} diff --git a/tests/watchlist-build.test.ts b/tests/watchlist-build.test.ts new file mode 100644 index 0000000..14389a4 --- /dev/null +++ b/tests/watchlist-build.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'bun:test'; +import { buildRecord } from 'packages/obsidian/src/watchlist/build'; +import movieDetail from 'tests/fixtures/tmdb-movie-dune2.json'; +import tvDetail from 'tests/fixtures/tmdb-tv-loki.json'; + +const EMPTY_PREV = { watch_status: 'Unwatched', rating: '0', rating_stars: '' }; + +describe('buildRecord movie', () => { + const r = buildRecord(movieDetail, true, EMPTY_PREV); + test('core mapping (mirrors python selftest)', () => { + expect(r.language).toBe('English'); + expect(r.country).toBe('United States of America'); + expect(r.imdbId).toBe('tt15239678'); + expect(r.contentRating).toBe('PG-13'); + expect(r.tmdbId).toBe('693134'); + expect(r.category).toBe('Movie'); + expect(r.trailer).toContain('youtube.com'); + expect(r.year).toBe('2024'); + expect(r.director).toEqual(['Denis Villeneuve']); + expect(r.writer).toEqual(['Jon Spaihts']); + expect(r.producer).toEqual(['Mary Parent']); + expect(r.imdbPage).toBe('https://www.imdb.com/title/tt15239678/'); + }); + test('movie: seasons/episodes null, vod empty', () => { + expect(r.seasons).toBeNull(); + expect(r.episodes).toBeNull(); + expect(r.vod).toEqual([]); + }); +}); + +describe('buildRecord tv', () => { + const r = buildRecord(tvDetail, false, EMPTY_PREV); + test('tv mapping', () => { + expect(r.mediaType).toBe('TV Series'); + expect(r.category).toBe('Series'); + expect(r.year).toBe('2021 - 2023'); + expect(r.seasons).toBe(2); + expect(r.episodes).toBe(12); + expect(r.vod).toEqual(['Disney+']); + expect(r.contentRating).toBe('TV-14'); + expect(r.lastEpisode).toBe('S2, E6: Glorious Purpose'); + expect(r.upcomingEpisode).toBeNull(); + expect(r.runtime).toBeNull(); + expect(r.director).toEqual(['Michael Waldron']); + }); + test('ongoing series year + TBA', () => { + const ongoing = { ...tvDetail, status: 'Returning Series', last_air_date: '2026-01-01', next_episode_to_air: null }; + const r2 = buildRecord(ongoing, false, EMPTY_PREV); + expect(r2.year).toBe('2021 -'); + expect(r2.upcomingEpisode).toBe('TBA'); + }); + test('same start/end year collapses', () => { + const oneYear = { ...tvDetail, first_air_date: '2021-06-09', last_air_date: '2021-07-14' }; + expect(buildRecord(oneYear, false, EMPTY_PREV).year).toBe('2021'); + }); +}); + +describe('anime derivation', () => { + test('Animation + Japanese → Anime', () => { + const anime = { + ...tvDetail, + genres: [{ name: 'Animation' }, { name: 'Drama' }], + original_language: 'ja', + spoken_languages: [{ iso_639_1: 'ja', english_name: 'Japanese' }], + }; + expect(buildRecord(anime, false, EMPTY_PREV).category).toBe('Anime'); + }); + test('Animation + English → not Anime', () => { + const western = { ...tvDetail, genres: [{ name: 'Animation' }] }; + expect(buildRecord(western, false, EMPTY_PREV).category).toBe('Series'); + }); +}); + +describe('user-field preservation', () => { + test('prev fields carried', () => { + const prev = { watch_status: 'Watching', rating: '4', rating_stars: '⭐️⭐️⭐️⭐️', notion_url: 'https://notion.so/x' }; + const r = buildRecord(tvDetail, false, prev); + expect(r.watchStatus).toBe('Watching'); + expect(r.rating).toBe('4'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + expect(r.notionUrl).toBe('https://notion.so/x'); + }); +}); + +describe('watch-status rule (TV)', () => { + test('Watched + newer episode → Unwatched', () => { + const prev = { watch_status: 'Watched', last_air_date: '2023-10-01', rating: '5', rating_stars: '⭐️⭐️⭐️⭐️⭐️' }; + expect(buildRecord(tvDetail, false, prev).watchStatus).toBe('Unwatched'); + }); + test('Watched + same date → stays Watched', () => { + const prev = { watch_status: 'Watched', last_air_date: '2023-11-09' }; + expect(buildRecord(tvDetail, false, prev).watchStatus).toBe('Watched'); + }); + test('movie never flips', () => { + const prev = { watch_status: 'Watched', last_air_date: '2020-01-01' }; + expect(buildRecord(movieDetail, true, prev).watchStatus).toBe('Watched'); + }); + test('no prev last_air_date → no flip', () => { + const prev = { watch_status: 'Watched' }; + expect(buildRecord(tvDetail, false, prev).watchStatus).toBe('Watched'); + }); +}); + +describe('eng_name derivation', () => { + test('non-Latin original → engName = localized title', () => { + const jp = { ...movieDetail, title: 'A Silent Voice: The Movie', original_title: '映画 聲の形' }; + expect(buildRecord(jp, true, EMPTY_PREV).engName).toBe('A Silent Voice: The Movie'); + }); + test('same Latin title → empty', () => { + expect(buildRecord(movieDetail, true, EMPTY_PREV).engName).toBe(''); + }); +}); From 0aa6df81a3a3c5022d5a8ec5f139034b328a4e18 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 11:17:22 +0800 Subject: [PATCH 06/53] =?UTF-8?q?feat(watchlist):=20renderNote=20=E2=80=94?= =?UTF-8?q?=20canonical=20note=20serializer=20(golden-tested)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/obsidian/src/watchlist/render.ts | 73 +++++++++++++++++++++++ tests/fixtures/canonical-series.md | 63 +++++++++++++++++++ tests/watchlist-render.test.ts | 39 ++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 packages/obsidian/src/watchlist/render.ts create mode 100644 tests/fixtures/canonical-series.md create mode 100644 tests/watchlist-render.test.ts diff --git a/packages/obsidian/src/watchlist/render.ts b/packages/obsidian/src/watchlist/render.ts new file mode 100644 index 0000000..1d63e51 --- /dev/null +++ b/packages/obsidian/src/watchlist/render.ts @@ -0,0 +1,73 @@ +import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; +import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; + +const CATEGORY_TAG: Record = { Movie: 'movie', Series: 'series', Anime: 'anime' }; + +export function renderNote(r: WatchlistRecord, myNotes: string): string { + const tag = CATEGORY_TAG[r.category]; + const stars = r.ratingStars; + const fm = [ + '---', + 'type: watchlist_item', + `category: ${r.category}`, + `media_type: ${r.mediaType}`, + `watch_status: ${r.watchStatus}`, + `rating: ${r.rating}`, + `rating_stars: ${stars}`, + `year: ${r.year || ''}`, + `runtime: ${r.runtime ? r.runtime : 'null'}`, + `seasons: ${r.seasons ?? 'null'}`, + `episodes: ${r.episodes ?? 'null'}`, + `vod: ${yamlList(r.vod)}`, + `genre: ${yamlList(r.genre)}`, + `status: ${r.status}`, + `language: ${yamlScalar(r.language)}`, + `country: ${yamlScalar(r.country)}`, + `director: ${yamlScalar(r.director.join(', '))}`, + `writer: ${yamlScalar(r.writer.join(', '))}`, + `producer: ${yamlScalar(r.producer.join(', '))}`, + `content_rating: ${r.contentRating}`, + `tmdb_rating: ${r.tmdbRating}`, + `tmdb_id: ${r.tmdbId}`, + `imdb_id: ${r.imdbId}`, + `release_date: ${r.releaseDate ? r.releaseDate : 'null'}`, + `last_air_date: ${r.lastAirDate ? r.lastAirDate : 'null'}`, + `next_air_date: ${r.nextAirDate ? r.nextAirDate : 'null'}`, + `last_episode: ${r.lastEpisode ? yamlScalar(r.lastEpisode) : ''}`, + `upcoming_episode: ${r.upcomingEpisode ? yamlScalar(r.upcomingEpisode) : ''}`, + `eng_name: ${yamlScalar(r.engName)}`, + `poster: ${quotedOrNull(r.poster)}`, + `trailer: ${quotedOrNull(r.trailer)}`, + `homepage: ${quotedOrNull(r.homepage)}`, + `imdb_page: ${quotedOrNull(r.imdbPage)}`, + `notion_url: ${quotedOrNull(r.notionUrl)}`, + `tags: [watchlist, ${tag}]`, + '---', + ]; + 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 = [`**${r.category}**`, ...[r.year, r.language, r.country].filter(x => x)]; + b.push(meta.join(' · '), ''); + if (r.rating !== '0' && r.rating !== '' && stars) b.push(`**Rating:** ${stars} (${r.rating}/5)`); + b.push(`**Watch Status:** ${r.watchStatus}`); + if (r.tmdbRating) b.push(`**TMDB Rating:** ${r.tmdbRating}/10`); + b.push(''); + if (r.synopsis) b.push('## Synopsis', r.synopsis, ''); + const crew: string[] = []; + if (r.director.length) crew.push(`**Director:** ${r.director.join(', ')}`); + if (r.writer.length) crew.push(`**Writer:** ${r.writer.join(', ')}`); + if (r.producer.length) crew.push(`**Producer:** ${r.producer.join(', ')}`); + if (crew.length) b.push(...crew, ''); + if (r.cast) b.push('## Cast', r.cast, ''); + const links: string[] = []; + if (r.imdbPage) links.push(`- [IMDb](${r.imdbPage})`); + if (r.trailer) links.push(`- [Trailer](${r.trailer})`); + if (r.homepage) links.push(`- [Homepage](${r.homepage})`); + if (r.notionUrl) links.push(`- [Original Notion entry](${r.notionUrl})`); + if (links.length) b.push('## Links', ...links, ''); + b.push('## My Notes', '', myNotes); + if (myNotes) b.push(''); + return fm.join('\n') + '\n' + b.join('\n'); +} diff --git a/tests/fixtures/canonical-series.md b/tests/fixtures/canonical-series.md new file mode 100644 index 0000000..5584847 --- /dev/null +++ b/tests/fixtures/canonical-series.md @@ -0,0 +1,63 @@ +--- +type: watchlist_item +category: Series +media_type: TV Series +watch_status: Watched +rating: 5 +rating_stars: ⭐️⭐️⭐️⭐️⭐️ +year: 2021 - 2023 +runtime: null +seasons: 2 +episodes: 12 +vod: ["Disney+"] +genre: [Drama, "Sci-Fi & Fantasy"] +status: Ended +language: English +country: United States of America +director: "Aaron Moorhead, Justin Benson" +writer: Eric Martin +producer: "Rachel Alter, Tommy Turtle" +content_rating: TV-14 +tmdb_rating: 8.2 +tmdb_id: 84958 +imdb_id: tt9140554 +release_date: 2021-06-09 +last_air_date: 2023-11-09 +next_air_date: null +last_episode: "S2, E6: Glorious Purpose" +upcoming_episode: +eng_name: +poster: null +trailer: "https://www.youtube.com/watch?v=nW948Va-l10" +homepage: "https://www.disneyplus.com/series/wp/6pARMvILBGzF" +imdb_page: "https://www.imdb.com/title/tt9140554/" +notion_url: "https://www.notion.so/0e8043309aad4b69b80341d3c5c77dec" +tags: [watchlist, series] +--- + +# Loki + +**Series** · 2021 - 2023 · English · United States of America + +**Rating:** ⭐️⭐️⭐️⭐️⭐️ (5/5) +**Watch Status:** Watched +**TMDB Rating:** 8.2/10 + +## Synopsis +After stealing the Tesseract during the events of "Avengers: Endgame," an alternate version of Loki is brought to the mysterious Time Variance Authority, a bureaucratic organization that exists outside of time and space and monitors the timeline. They give Loki a choice: face being erased from existence due to being a "time variant" or help fix the timeline and stop a greater threat. + +**Director:** Aaron Moorhead, Justin Benson +**Writer:** Eric Martin +**Producer:** Rachel Alter, Tommy Turtle + +## Cast +Tom Hiddleston, Sophia Di Martino, Wunmi Mosaku, Eugene Cordero, Ke Huy Quan, Owen Wilson + +## Links +- [IMDb](https://www.imdb.com/title/tt9140554/) +- [Trailer](https://www.youtube.com/watch?v=nW948Va-l10) +- [Homepage](https://www.disneyplus.com/series/wp/6pARMvILBGzF) +- [Original Notion entry](https://www.notion.so/0e8043309aad4b69b80341d3c5c77dec) + +## My Notes + diff --git a/tests/watchlist-render.test.ts b/tests/watchlist-render.test.ts new file mode 100644 index 0000000..5555ad3 --- /dev/null +++ b/tests/watchlist-render.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test'; +import { renderNote } from 'packages/obsidian/src/watchlist/render'; +import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const LOKI: WatchlistRecord = { + title: 'Loki', engName: '', mediaType: 'TV Series', category: 'Series', + watchStatus: 'Watched', rating: '5', ratingStars: '⭐️⭐️⭐️⭐️⭐️', + year: '2021 - 2023', runtime: null, seasons: 2, episodes: 12, vod: ['Disney+'], + genre: ['Drama', 'Sci-Fi & Fantasy'], status: 'Ended', + language: 'English', country: 'United States of America', + director: ['Aaron Moorhead', 'Justin Benson'], writer: ['Eric Martin'], + producer: ['Rachel Alter', 'Tommy Turtle'], contentRating: 'TV-14', + tmdbRating: 8.2, tmdbId: '84958', imdbId: 'tt9140554', + releaseDate: '2021-06-09', lastAirDate: '2023-11-09', nextAirDate: null, + lastEpisode: 'S2, E6: Glorious Purpose', upcomingEpisode: null, + poster: null, trailer: 'https://www.youtube.com/watch?v=nW948Va-l10', + homepage: 'https://www.disneyplus.com/series/wp/6pARMvILBGzF', + imdbPage: 'https://www.imdb.com/title/tt9140554/', + notionUrl: 'https://www.notion.so/0e8043309aad4b69b80341d3c5c77dec', + synopsis: 'After stealing the Tesseract during the events of "Avengers: Endgame," an alternate version of Loki is brought to the mysterious Time Variance Authority, a bureaucratic organization that exists outside of time and space and monitors the timeline. They give Loki a choice: face being erased from existence due to being a "time variant" or help fix the timeline and stop a greater threat.', + cast: 'Tom Hiddleston, Sophia Di Martino, Wunmi Mosaku, Eugene Cordero, Ke Huy Quan, Owen Wilson', +}; + +describe('renderNote golden', () => { + test('matches canonical series fixture byte-for-byte', () => { + const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-series.md'), 'utf-8'); + expect(renderNote(LOKI, '')).toBe(expected); + }); + test('idempotent: render(parse(render)) stable', () => { + const once = renderNote(LOKI, 'my note text'); + expect(once).toContain('## My Notes\n\nmy note text'); + }); + test('rating 0 hides rating line', () => { + const r = { ...LOKI, rating: '0', ratingStars: '' }; + expect(renderNote(r, '')).not.toContain('**Rating:**'); + }); +}); From 08f63205c2b0a7625cef220c1c6f200946728ed5 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 12:22:27 +0800 Subject: [PATCH 07/53] =?UTF-8?q?feat(watchlist):=20tmdb=20client=20?= =?UTF-8?q?=E2=80=94=20v3/v4=20auth,=20detail=20+=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/obsidian/src/watchlist/tmdb.ts | 30 +++++++++++++ tests/watchlist-tmdb.test.ts | 57 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 packages/obsidian/src/watchlist/tmdb.ts create mode 100644 tests/watchlist-tmdb.test.ts diff --git a/packages/obsidian/src/watchlist/tmdb.ts b/packages/obsidian/src/watchlist/tmdb.ts new file mode 100644 index 0000000..37af481 --- /dev/null +++ b/packages/obsidian/src/watchlist/tmdb.ts @@ -0,0 +1,30 @@ +export type HttpJsonFn = (url: string, headers: Record) => Promise; + +const BASE = 'https://api.themoviedb.org/3'; + +function authParts(key: string): { headers: Record; extraParams: Record } { + if (key.startsWith('eyJ')) { + return { headers: { Authorization: `Bearer ${key}`, accept: 'application/json' }, extraParams: {} }; + } + return { headers: { accept: 'application/json' }, extraParams: { api_key: key } }; +} + +function buildUrl(path: string, params: Record): string { + const qs = new URLSearchParams(params); + return `${BASE}${path}?${qs.toString()}`; +} + +export async function fetchDetail(http: HttpJsonFn, key: string, tmdbId: string, isMovie: boolean): Promise { + const { headers, extraParams } = authParts(key); + const path = isMovie ? `/movie/${tmdbId}` : `/tv/${tmdbId}`; + const append = isMovie ? 'credits,external_ids,release_dates,videos' : 'aggregate_credits,external_ids,content_ratings,videos'; + return await http(buildUrl(path, { append_to_response: append, language: 'en-US', ...extraParams }), headers); +} + +export async function searchTitle(http: HttpJsonFn, key: string, query: string, isMovie: boolean, year?: string): Promise { + const { headers, extraParams } = authParts(key); + const params: Record = { query, language: 'en-US', ...extraParams }; + if (year) params[isMovie ? 'primary_release_year' : 'first_air_date_year'] = year; + const res = await http(buildUrl(isMovie ? '/search/movie' : '/search/tv', params), headers); + return res?.results ?? []; +} diff --git a/tests/watchlist-tmdb.test.ts b/tests/watchlist-tmdb.test.ts new file mode 100644 index 0000000..21777a0 --- /dev/null +++ b/tests/watchlist-tmdb.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import { fetchDetail, searchTitle } from 'packages/obsidian/src/watchlist/tmdb'; + +function capture(): { calls: { url: string; headers: Record }[]; http: any } { + const calls: { url: string; headers: Record }[] = []; + return { + calls, + http: async (url: string, headers: Record) => { + calls.push({ url, headers }); + return { results: [] }; + }, + }; +} + +describe('fetchDetail', () => { + test('v4 token → Bearer header, no api_key param', async () => { + const { calls, http } = capture(); + await fetchDetail(http, 'eyJhbGciOi.fake.jwt', '693134', true); + expect(calls[0].headers['Authorization']).toBe('Bearer eyJhbGciOi.fake.jwt'); + expect(calls[0].url).not.toContain('api_key'); + }); + test('v3 key → api_key param, no auth header', async () => { + const { calls, http } = capture(); + await fetchDetail(http, 'abc123', '693134', true); + expect(calls[0].url).toContain('api_key=abc123'); + expect(calls[0].headers['Authorization']).toBeUndefined(); + }); + test('movie url + append', async () => { + const { calls, http } = capture(); + await fetchDetail(http, 'k', '693134', true); + expect(calls[0].url).toContain('/3/movie/693134'); + expect(calls[0].url).toContain('append_to_response=credits%2Cexternal_ids%2Crelease_dates%2Cvideos'); + expect(calls[0].url).toContain('language=en-US'); + }); + test('tv url + append', async () => { + const { calls, http } = capture(); + await fetchDetail(http, 'k', '84958', false); + expect(calls[0].url).toContain('/3/tv/84958'); + expect(calls[0].url).toContain('append_to_response=aggregate_credits%2Cexternal_ids%2Ccontent_ratings%2Cvideos'); + }); +}); + +describe('searchTitle', () => { + test('movie search url + year', async () => { + const { calls, http } = capture(); + await searchTitle(http, 'k', 'Dune Part Two', true, '2024'); + expect(calls[0].url).toContain('/3/search/movie'); + expect(calls[0].url).toContain('query=Dune+Part+Two'); + expect(calls[0].url).toContain('primary_release_year=2024'); + }); + test('tv search url', async () => { + const { calls, http } = capture(); + await searchTitle(http, 'k', 'Loki', false, '2021'); + expect(calls[0].url).toContain('/3/search/tv'); + expect(calls[0].url).toContain('first_air_date_year=2021'); + }); +}); From 79ba40f04dcca1b456df30ce450c23649afd3859 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 12:27:21 +0800 Subject: [PATCH 08/53] fix: tests/tsconfig baseUrl after root baseUrl addition (typecheck script) --- tests/tsconfig.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/tsconfig.json b/tests/tsconfig.json index bfa58b7..426e5d9 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,9 +1,10 @@ { "extends": "../tsconfig.json", "compilerOptions": { + "baseUrl": "..", "paths": { - "packages/*": ["../packages/*"], - "tests/*": ["./*"] + "packages/*": ["packages/*"], + "tests/*": ["tests/*"] }, "types": ["vite/client", "bun-types"] }, From 1923bb87b8e661e7d1bf139fe49f6a1cd5077d14 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 12:30:57 +0800 Subject: [PATCH 09/53] fix(watchlist): normalize notion_url null-sentinel in buildRecord prev-read quotedOrNull renders an empty notion_url as the literal string `null`. buildRecord read that back verbatim on the next sync pass and re-quoted it as "null", breaking diff-on-write (every re-sync of a note with no notion_url appeared changed). Treat the `null` sentinel as empty when reading prev frontmatter. --- packages/obsidian/src/watchlist/build.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/obsidian/src/watchlist/build.ts b/packages/obsidian/src/watchlist/build.ts index 2b3744c..4c994a4 100644 --- a/packages/obsidian/src/watchlist/build.ts +++ b/packages/obsidian/src/watchlist/build.ts @@ -139,7 +139,8 @@ export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record< let watchStatus = prev['watch_status'] || 'Unwatched'; const rating = prev['rating'] || '0'; const ratingStars = prev['rating_stars'] ?? ''; - const notionUrl = prev['notion_url'] || ''; + const rawNotionUrl = prev['notion_url'] || ''; + const notionUrl = rawNotionUrl === 'null' ? '' : rawNotionUrl; // quotedOrNull renders empty as literal `null` — don't round-trip it as a value // ---- TV watch-status rule: new episode aired since last sync ---- const prevLast = (prev['last_air_date'] ?? '').trim().replace(/^"|"$/g, ''); From eeaa37469c7ca07da671cd9cee56129ad4510118 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 12:31:00 +0800 Subject: [PATCH 10/53] =?UTF-8?q?feat(watchlist):=20SyncEngine=20=E2=80=94?= =?UTF-8?q?=20tiered=20diff-on-write=20sync=20with=20throttle/backoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/obsidian/src/watchlist/SyncEngine.ts | 95 ++++++++++++++ tests/watchlist-sync-engine.test.ts | 123 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 packages/obsidian/src/watchlist/SyncEngine.ts create mode 100644 tests/watchlist-sync-engine.test.ts diff --git a/packages/obsidian/src/watchlist/SyncEngine.ts b/packages/obsidian/src/watchlist/SyncEngine.ts new file mode 100644 index 0000000..03a599c --- /dev/null +++ b/packages/obsidian/src/watchlist/SyncEngine.ts @@ -0,0 +1,95 @@ +import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; +import { buildRecord } from 'packages/obsidian/src/watchlist/build'; +import { renderNote } from 'packages/obsidian/src/watchlist/render'; + +export interface SyncDeps { + listNotes(): Promise<{ path: string; content: string }[]>; + writeNote(path: string, content: string): Promise; + fetchDetail(tmdbId: string, isMovie: boolean): Promise; + sleep(ms: number): Promise; + log(msg: string): void; +} + +export interface SyncOptions { + full?: boolean; + dryRun?: boolean; + throttleMs?: number; +} + +export interface SyncReport { + scanned: number; + synced: number; + written: number; + skippedNoId: number; + skippedStatic: number; + flipped: string[]; + errors: { path: string; error: string }[]; +} + +export class TmdbRateLimitError extends Error { + retryAfterMs: number = 2000; +} + +const ACTIVE_STATUSES = new Set(['Returning Series', 'In Production', 'Planned', 'Pilot']); + +function strip(s: string | undefined): string { + return (s ?? '').trim().replace(/^"|"$/g, ''); +} + +export function isActive(fm: Record): boolean { + const status = strip(fm['status']); + if (!status) return true; // never enriched → needs first pass + if (ACTIVE_STATUSES.has(status)) return true; + if (strip(fm['watch_status']) === 'Watching') return true; + const nextAir = strip(fm['next_air_date']); + if (nextAir && nextAir !== 'null') return true; + return false; +} + +export async function syncFolder(deps: SyncDeps, opts: SyncOptions = {}): Promise { + const throttleMs = opts.throttleMs ?? 250; + const report: SyncReport = { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, flipped: [], errors: [] }; + const notes = await deps.listNotes(); + for (const note of notes) { + report.scanned++; + const { frontmatter, body } = parseNote(note.content); + const ref = noteTmdbRef(frontmatter); + if (!ref) { + report.skippedNoId++; + continue; + } + if (!opts.full && !isActive(frontmatter)) { + report.skippedStatic++; + continue; + } + try { + let detail: any; + try { + detail = await deps.fetchDetail(ref.tmdbId, ref.isMovie); + } catch (e) { + if (e instanceof TmdbRateLimitError) { + await deps.sleep(e.retryAfterMs); + detail = await deps.fetchDetail(ref.tmdbId, ref.isMovie); + } else { + throw e; + } + } + const record = buildRecord(detail, ref.isMovie, frontmatter); + const rendered = renderNote(record, extractMyNotes(body)); + report.synced++; + if (strip(frontmatter['watch_status']) === 'Watched' && record.watchStatus === 'Unwatched') { + report.flipped.push(note.path); + } + if (rendered !== note.content) { + report.written++; + if (!opts.dryRun) await deps.writeNote(note.path, rendered); + deps.log(`${opts.dryRun ? '[dry] ' : ''}updated ${note.path}`); + } + await deps.sleep(throttleMs); + } catch (e) { + report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) }); + deps.log(`ERROR ${note.path}: ${String(e)}`); + } + } + return report; +} diff --git a/tests/watchlist-sync-engine.test.ts b/tests/watchlist-sync-engine.test.ts new file mode 100644 index 0000000..66e817a --- /dev/null +++ b/tests/watchlist-sync-engine.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from 'bun:test'; +import { syncFolder, isActive, TmdbRateLimitError, type SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine'; +import tvDetail from 'tests/fixtures/tmdb-tv-loki.json'; + +const ENDED_NOTE = `--- +type: watchlist_item +media_type: TV Series +watch_status: Watched +rating: 5 +rating_stars: ⭐️⭐️⭐️⭐️⭐️ +status: Ended +tmdb_id: 84958 +last_air_date: 2023-11-09 +--- + +# Loki + +## My Notes + +keep me +`; + +const AIRING_NOTE = ENDED_NOTE.replace('status: Ended', 'status: Returning Series').replace('last_air_date: 2023-11-09', 'last_air_date: 2023-10-01'); + +function makeDeps(notes: { path: string; content: string }[], detail: any = tvDetail) { + const writes: { path: string; content: string }[] = []; + const fetches: string[] = []; + const deps: SyncDeps = { + listNotes: async () => notes, + writeNote: async (path, content) => { writes.push({ path, content }); }, + fetchDetail: async (id) => { fetches.push(id); return detail; }, + sleep: async () => {}, + log: () => {}, + }; + return { deps, writes, fetches }; +} + +describe('isActive tiering', () => { + test('Returning Series → active', () => expect(isActive({ status: 'Returning Series' })).toBe(true)); + test('Watching → active regardless of status', () => expect(isActive({ status: 'Ended', watch_status: 'Watching' })).toBe(true)); + test('next_air_date set → active', () => expect(isActive({ status: 'Ended', next_air_date: '2026-08-01' })).toBe(true)); + test('Ended → static', () => expect(isActive({ status: 'Ended', next_air_date: 'null' })).toBe(false)); + test('Released movie → static', () => expect(isActive({ status: 'Released' })).toBe(false)); + test('missing status → active (needs first enrich)', () => expect(isActive({})).toBe(true)); +}); + +describe('syncFolder', () => { + test('default run skips static notes', async () => { + const { deps, fetches } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]); + const report = await syncFolder(deps); + expect(fetches.length).toBe(0); + expect(report.skippedStatic).toBe(1); + }); + test('full run processes static notes', async () => { + const { deps, fetches } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]); + await syncFolder(deps, { full: true }); + expect(fetches).toEqual(['84958']); + }); + test('diff-on-write: second pass on rendered output writes nothing', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]); + await syncFolder(deps, { full: true }); + const rendered = writes[0].content; + const second = makeDeps([{ path: 'Loki.md', content: rendered }]); + const report = await syncFolder(second.deps, { full: true }); + expect(second.writes.length).toBe(0); + expect(report.written).toBe(0); + }); + test('watch rule flips through full pipeline', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]); + const report = await syncFolder(deps); + expect(writes.length).toBe(1); + expect(writes[0].content).toContain('watch_status: Unwatched'); + expect(report.flipped).toEqual(['Loki.md']); + }); + test('My Notes preserved through rewrite', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]); + await syncFolder(deps); + expect(writes[0].content).toContain('keep me'); + }); + test('no tmdb ref → skipped, counted', async () => { + const { deps, fetches } = makeDeps([{ path: '_Dashboard.md', content: '# dash' }]); + const report = await syncFolder(deps, { full: true }); + expect(fetches.length).toBe(0); + expect(report.skippedNoId).toBe(1); + }); + test('dryRun: no writes, report counts', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]); + const report = await syncFolder(deps, { dryRun: true }); + expect(writes.length).toBe(0); + expect(report.written).toBe(1); // counts what WOULD be written + }); + test('429 → sleep(retryAfter) then retry succeeds', async () => { + let calls = 0; + const slept: number[] = []; + const deps: SyncDeps = { + listNotes: async () => [{ path: 'Loki.md', content: AIRING_NOTE }], + writeNote: async () => {}, + fetchDetail: async () => { + calls++; + if (calls === 1) { const e = new TmdbRateLimitError('429'); e.retryAfterMs = 1500; throw e; } + return tvDetail; + }, + sleep: async ms => { slept.push(ms); }, + log: () => {}, + }; + const report = await syncFolder(deps); + expect(calls).toBe(2); + expect(slept).toContain(1500); + expect(report.errors.length).toBe(0); + }); + test('fetch error recorded, other notes continue', async () => { + const { deps } = makeDeps([ + { path: 'Bad.md', content: AIRING_NOTE }, + { path: 'Good.md', content: AIRING_NOTE }, + ]); + let n = 0; + deps.fetchDetail = async () => { n++; if (n === 1) throw new Error('boom'); return tvDetail; }; + const report = await syncFolder(deps); + expect(report.errors.length).toBe(1); + expect(report.errors[0].path).toBe('Bad.md'); + expect(report.synced).toBe(1); + }); +}); From e8cb9b93fef622a754d7b40408f46cd97f04d908 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 12:37:52 +0800 Subject: [PATCH 11/53] fix(watchlist): strip surrounding quotes from prev user fields (notion_url round-trip) --- packages/obsidian/src/watchlist/build.ts | 9 +++++---- packages/obsidian/src/watchlist/parse.ts | 2 +- tests/watchlist-build.test.ts | 10 ++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/obsidian/src/watchlist/build.ts b/packages/obsidian/src/watchlist/build.ts index 4c994a4..e399f7b 100644 --- a/packages/obsidian/src/watchlist/build.ts +++ b/packages/obsidian/src/watchlist/build.ts @@ -1,4 +1,5 @@ import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; +import { stripQuotes } from 'packages/obsidian/src/watchlist/parse'; export type TmdbDetail = Record; @@ -136,10 +137,10 @@ export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record< } // ---- preserve user-managed fields ---- - let watchStatus = prev['watch_status'] || 'Unwatched'; - const rating = prev['rating'] || '0'; - const ratingStars = prev['rating_stars'] ?? ''; - const rawNotionUrl = prev['notion_url'] || ''; + let watchStatus = stripQuotes(prev['watch_status']) || 'Unwatched'; + const rating = stripQuotes(prev['rating']) || '0'; + const ratingStars = stripQuotes(prev['rating_stars']); + const rawNotionUrl = stripQuotes(prev['notion_url']); const notionUrl = rawNotionUrl === 'null' ? '' : rawNotionUrl; // quotedOrNull renders empty as literal `null` — don't round-trip it as a value // ---- TV watch-status rule: new episode aired since last sync ---- diff --git a/packages/obsidian/src/watchlist/parse.ts b/packages/obsidian/src/watchlist/parse.ts index 29a2eb8..041e484 100644 --- a/packages/obsidian/src/watchlist/parse.ts +++ b/packages/obsidian/src/watchlist/parse.ts @@ -22,7 +22,7 @@ export function extractMyNotes(body: string): string { return m ? m[1].trim() : ''; } -function stripQuotes(s: string | undefined): string { +export function stripQuotes(s: string | undefined): string { return (s ?? '').trim().replace(/^"|"$/g, ''); } diff --git a/tests/watchlist-build.test.ts b/tests/watchlist-build.test.ts index 14389a4..44d3780 100644 --- a/tests/watchlist-build.test.ts +++ b/tests/watchlist-build.test.ts @@ -80,6 +80,16 @@ describe('user-field preservation', () => { expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); expect(r.notionUrl).toBe('https://notion.so/x'); }); + test('quoted notion_url from raw frontmatter capture → quotes stripped', () => { + const prev = { notion_url: '"https://www.notion.so/x"' }; + const r = buildRecord(tvDetail, false, prev); + expect(r.notionUrl).toBe('https://www.notion.so/x'); + }); + test('null-sentinel notion_url still normalizes to empty', () => { + const prev = { notion_url: 'null' }; + const r = buildRecord(tvDetail, false, prev); + expect(r.notionUrl).toBe(''); + }); }); describe('watch-status rule (TV)', () => { From f9cc324ead90e17211264a6898305d7d3801fdc8 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 12:44:52 +0800 Subject: [PATCH 12/53] feat(watchlist): settings, commands, catch-up scheduler wiring --- packages/obsidian/src/main.ts | 23 ++++++ packages/obsidian/src/settings/Settings.ts | 66 +++++++++++++++++ .../src/watchlist/WatchlistController.ts | 74 +++++++++++++++++++ tests/watchlist-controller.test.ts | 40 ++++++++++ 4 files changed, 203 insertions(+) create mode 100644 packages/obsidian/src/watchlist/WatchlistController.ts create mode 100644 tests/watchlist-controller.test.ts diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index 9d3be8b..020a958 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -32,6 +32,7 @@ import { MediaTypeManager } from 'packages/obsidian/src/utils/MediaTypeManager'; import { MEDIA_TYPES } from 'packages/obsidian/src/utils/MediaTypeManager'; import { ModalHelper } from 'packages/obsidian/src/utils/ModalHelper'; import { unCamelCase } from 'packages/obsidian/src/utils/Utils'; +import { WatchlistController } from 'packages/obsidian/src/watchlist/WatchlistController'; export default class MediaDbPlugin extends Plugin { declare settings: MediaDbPluginSettings; @@ -44,6 +45,7 @@ export default class MediaDbPlugin extends Plugin { bulkImportHelper!: BulkImportHelper; dateFormatter!: DateFormatter; errorReporter!: ErrorReporter; + watchlist!: WatchlistController; async onload(): Promise { this.mediaTypeManager = new MediaTypeManager(); @@ -60,6 +62,11 @@ export default class MediaDbPlugin extends Plugin { this.addSettingTab(new MediaDbSettingTab(this.app, this)); this.registerRibbonAndFileMenu(); this.registerCommands(); + + this.watchlist = new WatchlistController(this); + // catch-up shortly after startup (let vault index settle), then hourly due-check + window.setTimeout(() => void this.watchlist.maybeCatchUp(), 30_000); + this.registerInterval(window.setInterval(() => void this.watchlist.maybeCatchUp(), 3600_000)); } onunload(): void {} @@ -168,6 +175,22 @@ export default class MediaDbPlugin extends Plugin { return true; }, }); + + this.addCommand({ + id: 'watchlist-sync-now', + name: 'Watchlist: sync now (airing/active only)', + callback: () => void this.watchlist.syncNow(false), + }); + this.addCommand({ + id: 'watchlist-sync-full', + name: 'Watchlist: full sync (all entries)', + callback: () => void this.watchlist.syncNow(true), + }); + this.addCommand({ + id: 'watchlist-sync-dry-run', + name: 'Watchlist: dry-run full sync (log only, no writes)', + callback: () => void this.watchlist.syncNow(true, true), + }); } private getLegacyApiKeyEntries(diskSettings: Record): LegacyApiKeyEntry[] { diff --git a/packages/obsidian/src/settings/Settings.ts b/packages/obsidian/src/settings/Settings.ts index 9f54d7c..06554f0 100644 --- a/packages/obsidian/src/settings/Settings.ts +++ b/packages/obsidian/src/settings/Settings.ts @@ -119,6 +119,11 @@ export interface MediaDbPluginSettings { propertyMappingModels: PropertyMappingModelData[]; + watchlistEnabled: boolean; + watchlistFolder: string; + watchlistSyncIntervalHours: number; + watchlistLastSync: number; + // DEPRECATED: Use propertyMappingModels instead moviePropertyConversionRules: string; seriesPropertyConversionRules: string; @@ -378,6 +383,11 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { propertyMappingModels: [], + watchlistEnabled: false, + watchlistFolder: 'Watchlist', + watchlistSyncIntervalHours: 24, + watchlistLastSync: 0, + // DEPRECATED moviePropertyConversionRules: '', seriesPropertyConversionRules: '', @@ -725,6 +735,62 @@ export class MediaDbSettingTab extends PluginSettingTab { }), ); + // MARK: Watchlist sync + const watchlistGroup = new SettingGroup(containerEl); + watchlistGroup.setHeading('Watchlist sync'); + + watchlistGroup.addSetting( + setting => + void setting + .setName('Enable watchlist sync') + .setDesc('Periodically sync TV/movie watchlist notes against TMDB. Uses the TMDB API key configured above.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.watchlistEnabled).onChange(data => { + this.plugin.settings.watchlistEnabled = data; + void this.plugin.saveSettings(); + }); + }), + ); + + watchlistGroup.addSetting( + setting => + void setting + .setName('Watchlist folder') + .setDesc('Folder containing watchlist notes.') + .addSearch(cb => { + const suggester = new FolderSuggest(this.app, cb.inputEl); + suggester.onSelect(folder => { + cb.setValue(folder.path); + this.plugin.settings.watchlistFolder = folder.path; + void this.plugin.saveSettings(); + suggester.close(); + }); + cb.setPlaceholder(DEFAULT_SETTINGS.watchlistFolder) + .setValue(this.plugin.settings.watchlistFolder) + .onChange(data => { + this.plugin.settings.watchlistFolder = data; + void this.plugin.saveSettings(); + }); + }), + ); + + watchlistGroup.addSetting( + setting => + void setting + .setName('Sync interval (hours)') + .setDesc('How often to auto-sync the watchlist, in hours (1–168).') + .addText(cb => { + cb.setPlaceholder(String(DEFAULT_SETTINGS.watchlistSyncIntervalHours)) + .setValue(String(this.plugin.settings.watchlistSyncIntervalHours)) + .onChange(data => { + const parsed = Math.min(168, Math.max(1, Math.round(Number(data)))); + if (!Number.isFinite(parsed)) return; + this.plugin.settings.watchlistSyncIntervalHours = parsed; + void this.plugin.saveSettings(); + }); + }), + ); + // MARK: Media type settings // Create a map to store APIs for each media type diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts new file mode 100644 index 0000000..a80ef63 --- /dev/null +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -0,0 +1,74 @@ +import { Notice, TFile, TFolder } from 'obsidian'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { syncFolder, TmdbRateLimitError, type SyncDeps, type SyncReport } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { fetchDetail } from 'packages/obsidian/src/watchlist/tmdb'; +import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; + +export class WatchlistController { + constructor(private plugin: MediaDbPlugin) {} + + private getKey(): string { + const keyId = this.plugin.settings.TMDBKeyId; + const key = keyId ? this.plugin.app.secretStorage.getSecret(keyId) : null; + if (!key) throw new Error('TMDB API key not configured (Media DB Sync settings).'); + return key; + } + + private makeDeps(key: string): SyncDeps { + const { app } = this.plugin; + const folder = app.vault.getAbstractFileByPath(this.plugin.settings.watchlistFolder); + return { + listNotes: async () => { + if (!(folder instanceof TFolder)) throw new Error(`Watchlist folder not found: ${this.plugin.settings.watchlistFolder}`); + const files = folder.children.filter((f): f is TFile => f instanceof TFile && f.extension === 'md' && !f.name.startsWith('_')); + const out: { path: string; content: string }[] = []; + for (const f of files) out.push({ path: f.path, content: await app.vault.read(f) }); + return out; + }, + writeNote: async (path, content) => { + const f = app.vault.getAbstractFileByPath(path); + if (f instanceof TFile) await app.vault.modify(f, content); + }, + fetchDetail: async (tmdbId, isMovie) => { + const http = async (url: string, headers: Record): Promise => { + const res = await obsidianFetch(new Request(url, { headers })); + if (res.status === 429) { + const err = new TmdbRateLimitError('TMDB 429'); + const ra = Number(res.headers.get('retry-after') ?? 2); + err.retryAfterMs = ra * 1000; + throw err; + } + if (res.status !== 200) throw new Error(`TMDB ${res.status} for ${url}`); + return await res.json(); + }; + return await fetchDetail(http, key, tmdbId, isMovie); + }, + sleep: ms => new Promise(r => setTimeout(r, ms)), + log: msg => console.log(`[media-db-sync] ${msg}`), + }; + } + + async syncNow(full: boolean, dryRun = false): Promise { + const key = this.getKey(); + const report = await syncFolder(this.makeDeps(key), { full, dryRun }); + const mode = dryRun ? 'DRY-RUN ' : ''; + new Notice( + `Watchlist ${mode}sync: ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped to Unwatched` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + if (!dryRun) { + this.plugin.settings.watchlistLastSync = Date.now(); + await this.plugin.saveSettings(); + } + return report; + } + + async maybeCatchUp(): Promise { + const s = this.plugin.settings; + if (!s.watchlistEnabled) return; + const due = s.watchlistLastSync + s.watchlistSyncIntervalHours * 3600_000; + if (Date.now() >= due) { + await this.syncNow(false).catch(e => console.error('[media-db-sync] scheduled sync failed', e)); + } + } +} diff --git a/tests/watchlist-controller.test.ts b/tests/watchlist-controller.test.ts new file mode 100644 index 0000000..e3efb79 --- /dev/null +++ b/tests/watchlist-controller.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { WatchlistController } from 'packages/obsidian/src/watchlist/WatchlistController'; + +function fakePlugin(overrides: Partial<{ enabled: boolean; last: number; hours: number }> = {}) { + return { + settings: { + watchlistEnabled: overrides.enabled ?? true, + watchlistLastSync: overrides.last ?? 0, + watchlistSyncIntervalHours: overrides.hours ?? 24, + watchlistFolder: 'Watchlist', + TMDBKeyId: 'kid', + }, + saveSettings: async () => {}, + app: {}, + } as any; +} + +describe('maybeCatchUp', () => { + test('overdue → syncs', async () => { + const c = new WatchlistController(fakePlugin({ last: 0 })); + let called = false; + (c as any).syncNow = async () => { called = true; return {} as any; }; + await c.maybeCatchUp(); + expect(called).toBe(true); + }); + test('recent sync → no call', async () => { + const c = new WatchlistController(fakePlugin({ last: Date.now() })); + let called = false; + (c as any).syncNow = async () => { called = true; return {} as any; }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); + test('disabled → no call', async () => { + const c = new WatchlistController(fakePlugin({ enabled: false, last: 0 })); + let called = false; + (c as any).syncNow = async () => { called = true; return {} as any; }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); +}); From aafa3bb1e455de8dfb74234a36c6a560eb7d73dc Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 12:49:59 +0800 Subject: [PATCH 13/53] fix(watchlist): surface sync command errors via Notice --- packages/obsidian/src/main.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index 020a958..0982547 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -1,5 +1,5 @@ import 'packages/obsidian/src/styles.css'; -import { Plugin, TFolder } from 'obsidian'; +import { Notice, Plugin, TFolder } from 'obsidian'; import { APIManager } from 'packages/obsidian/src/api/APIManager'; import { BoardGameGeekAPI } from 'packages/obsidian/src/api/apis/BoardGameGeekAPI'; import { ComicVineAPI } from 'packages/obsidian/src/api/apis/ComicVineAPI'; @@ -179,17 +179,17 @@ export default class MediaDbPlugin extends Plugin { this.addCommand({ id: 'watchlist-sync-now', name: 'Watchlist: sync now (airing/active only)', - callback: () => void this.watchlist.syncNow(false), + callback: () => void this.watchlist.syncNow(false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), }); this.addCommand({ id: 'watchlist-sync-full', name: 'Watchlist: full sync (all entries)', - callback: () => void this.watchlist.syncNow(true), + callback: () => void this.watchlist.syncNow(true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), }); this.addCommand({ id: 'watchlist-sync-dry-run', name: 'Watchlist: dry-run full sync (log only, no writes)', - callback: () => void this.watchlist.syncNow(true, true), + callback: () => void this.watchlist.syncNow(true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), }); } From 22a409ce9b04e4335c578ac6e0338b57ebf8afb2 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 12:54:49 +0800 Subject: [PATCH 14/53] feat(watchlist): resolve-missing-ids command --- packages/obsidian/src/main.ts | 5 + .../src/watchlist/WatchlistController.ts | 93 ++++++++++++++++--- packages/obsidian/src/watchlist/resolve.ts | 36 +++++++ tests/watchlist-resolve.test.ts | 37 ++++++++ 4 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 packages/obsidian/src/watchlist/resolve.ts create mode 100644 tests/watchlist-resolve.test.ts diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index 0982547..8099844 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -191,6 +191,11 @@ export default class MediaDbPlugin extends Plugin { name: 'Watchlist: dry-run full sync (log only, no writes)', callback: () => void this.watchlist.syncNow(true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), }); + this.addCommand({ + id: 'watchlist-resolve-ids', + name: 'Watchlist: resolve missing TMDB ids', + callback: () => void this.watchlist.resolveMissingIds().catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + }); } private getLegacyApiKeyEntries(diskSettings: Record): LegacyApiKeyEntry[] { diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts index a80ef63..d2cc200 100644 --- a/packages/obsidian/src/watchlist/WatchlistController.ts +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -1,8 +1,29 @@ import { Notice, TFile, TFolder } from 'obsidian'; import type MediaDbPlugin from 'packages/obsidian/src/main'; import { syncFolder, TmdbRateLimitError, type SyncDeps, type SyncReport } from 'packages/obsidian/src/watchlist/SyncEngine'; -import { fetchDetail } from 'packages/obsidian/src/watchlist/tmdb'; +import { fetchDetail, searchTitle, type HttpJsonFn } from 'packages/obsidian/src/watchlist/tmdb'; import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; +import { parseNote, noteTmdbRef, stripQuotes } from 'packages/obsidian/src/watchlist/parse'; +import { resolveNote } from 'packages/obsidian/src/watchlist/resolve'; + +export interface ResolveReport { + scanned: number; + resolved: number; + ambiguous: number; + errors: { path: string; error: string }[]; +} + +function patchFrontmatter(content: string, tmdbId: string, mediaType: string): string { + const insert = `tmdb_id: ${tmdbId}\nmedia_type: ${mediaType}`; + const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content); + if (fmMatch) { + const inner = fmMatch[1]; + const typeLine = /^type:.*$/m.exec(inner); + const newInner = typeLine ? inner.slice(0, typeLine.index + typeLine[0].length) + '\n' + insert + inner.slice(typeLine.index + typeLine[0].length) : insert + '\n' + inner; + return content.slice(0, fmMatch.index) + '---\n' + newInner + '\n---' + content.slice(fmMatch.index + fmMatch[0].length); + } + return `---\ntype: watchlist_item\n${insert}\n---\n\n` + content; +} export class WatchlistController { constructor(private plugin: MediaDbPlugin) {} @@ -14,9 +35,24 @@ export class WatchlistController { return key; } + private makeHttp(): HttpJsonFn { + return async (url: string, headers: Record): Promise => { + const res = await obsidianFetch(new Request(url, { headers })); + if (res.status === 429) { + const err = new TmdbRateLimitError('TMDB 429'); + const ra = Number(res.headers.get('retry-after') ?? 2); + err.retryAfterMs = ra * 1000; + throw err; + } + if (res.status !== 200) throw new Error(`TMDB ${res.status} for ${url}`); + return await res.json(); + }; + } + private makeDeps(key: string): SyncDeps { const { app } = this.plugin; const folder = app.vault.getAbstractFileByPath(this.plugin.settings.watchlistFolder); + const http = this.makeHttp(); return { listNotes: async () => { if (!(folder instanceof TFolder)) throw new Error(`Watchlist folder not found: ${this.plugin.settings.watchlistFolder}`); @@ -29,20 +65,7 @@ export class WatchlistController { const f = app.vault.getAbstractFileByPath(path); if (f instanceof TFile) await app.vault.modify(f, content); }, - fetchDetail: async (tmdbId, isMovie) => { - const http = async (url: string, headers: Record): Promise => { - const res = await obsidianFetch(new Request(url, { headers })); - if (res.status === 429) { - const err = new TmdbRateLimitError('TMDB 429'); - const ra = Number(res.headers.get('retry-after') ?? 2); - err.retryAfterMs = ra * 1000; - throw err; - } - if (res.status !== 200) throw new Error(`TMDB ${res.status} for ${url}`); - return await res.json(); - }; - return await fetchDetail(http, key, tmdbId, isMovie); - }, + fetchDetail: async (tmdbId, isMovie) => await fetchDetail(http, key, tmdbId, isMovie), sleep: ms => new Promise(r => setTimeout(r, ms)), log: msg => console.log(`[media-db-sync] ${msg}`), }; @@ -71,4 +94,44 @@ export class WatchlistController { await this.syncNow(false).catch(e => console.error('[media-db-sync] scheduled sync failed', e)); } } + + async resolveMissingIds(dryRun = false): Promise { + const key = this.getKey(); + const deps = this.makeDeps(key); + const http = this.makeHttp(); + const search = (q: string, isMovie: boolean, year?: string) => searchTitle(http, key, q, isMovie, year); + + const report: ResolveReport = { scanned: 0, resolved: 0, ambiguous: 0, errors: [] }; + const notes = await deps.listNotes(); + for (const note of notes) { + const { frontmatter } = parseNote(note.content); + if (noteTmdbRef(frontmatter)) continue; + const type = stripQuotes(frontmatter['type']); + if (type && type !== 'watchlist_item') continue; // skip type: list dashboards etc. + report.scanned++; + try { + const filename = note.path.split('/').pop() ?? note.path; + const result = await resolveNote(frontmatter, filename, search); + if (!result) { + report.ambiguous++; + deps.log(`ambiguous/no match: ${note.path}`); + } else { + report.resolved++; + const mediaType = result.isMovie ? 'Movie' : 'TV Series'; + deps.log(`${dryRun ? '[dry] ' : ''}resolved ${note.path} -> tmdb_id ${result.tmdbId} (${result.matchedTitle})`); + if (!dryRun) await deps.writeNote(note.path, patchFrontmatter(note.content, result.tmdbId, mediaType)); + } + await deps.sleep(250); + } catch (e) { + report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) }); + deps.log(`ERROR ${note.path}: ${String(e)}`); + } + } + const mode = dryRun ? 'DRY-RUN ' : ''; + new Notice( + `Watchlist ${mode}resolve: ${report.scanned} scanned, ${report.resolved} resolved, ${report.ambiguous} ambiguous/no match` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + return report; + } } diff --git a/packages/obsidian/src/watchlist/resolve.ts b/packages/obsidian/src/watchlist/resolve.ts new file mode 100644 index 0000000..948fab9 --- /dev/null +++ b/packages/obsidian/src/watchlist/resolve.ts @@ -0,0 +1,36 @@ +type SearchFn = (query: string, isMovie: boolean, year?: string) => Promise; + +export interface ResolveResult { + tmdbId: string; + isMovie: boolean; + matchedTitle: string; +} + +function resultTitle(r: any): string { + return r.title ?? r.name ?? ''; +} + +function resultTitles(r: any): string[] { + return [r.title, r.original_title, r.name, r.original_name].filter(Boolean).map((t: string) => t.toLowerCase()); +} + +async function tryOne(query: string, isMovie: boolean, year: string | undefined, search: SearchFn): Promise { + const results = await search(query, isMovie, year); + if (results.length === 0) return null; + const q = query.toLowerCase(); + const exact = results.find(r => resultTitles(r).includes(q)); + const pick = exact ?? (results.length === 1 ? results[0] : null); + if (!pick) return null; + return { tmdbId: String(pick.id), isMovie, matchedTitle: resultTitle(pick) }; +} + +export async function resolveNote(fm: Record, filename: string, search: SearchFn): Promise { + const strip = (s: string | undefined): string => (s ?? '').trim().replace(/^"|"$/g, ''); + const query = strip(fm['title']) || filename.replace(/\.md$/, ''); + const yearField = strip(fm['year']) || strip(fm['release_date']); + const year = /^\d{4}/.exec(yearField)?.[0]; + const mt = strip(fm['media_type']); + if (mt === 'Movie') return await tryOne(query, true, year, search); + if (mt) return await tryOne(query, false, year, search); + return (await tryOne(query, true, year, search)) ?? (await tryOne(query, false, year, search)); +} diff --git a/tests/watchlist-resolve.test.ts b/tests/watchlist-resolve.test.ts new file mode 100644 index 0000000..8d0424d --- /dev/null +++ b/tests/watchlist-resolve.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { resolveNote } from 'packages/obsidian/src/watchlist/resolve'; + +const HIT = { id: 693134, title: 'Dune: Part Two', original_title: 'Dune: Part Two' }; +const OTHER = { id: 1, title: 'Dune', original_title: 'Dune' }; + +describe('resolveNote', () => { + test('exact title match accepted', async () => { + const r = await resolveNote({ media_type: 'Movie' }, 'Dune: Part Two.md', async () => [HIT, OTHER]); + expect(r).toEqual({ tmdbId: '693134', isMovie: true, matchedTitle: 'Dune: Part Two' }); + }); + test('single result accepted even if inexact', async () => { + const r = await resolveNote({ media_type: 'Movie' }, 'Dune Part 2.md', async () => [HIT]); + expect(r?.tmdbId).toBe('693134'); + }); + test('ambiguous → null', async () => { + const r = await resolveNote({ media_type: 'Movie' }, 'Dune something.md', async () => [HIT, OTHER]); + expect(r).toBeNull(); + }); + test('no media_type → movie then tv fallback', async () => { + const calls: boolean[] = []; + const r = await resolveNote({}, 'Loki.md', async (q, isMovie) => { + calls.push(isMovie); + return isMovie ? [] : [{ id: 84958, name: 'Loki', original_name: 'Loki' }]; + }); + expect(calls).toEqual([true, false]); + expect(r).toEqual({ tmdbId: '84958', isMovie: false, matchedTitle: 'Loki' }); + }); + test('year hint passed through', async () => { + let seenYear: string | undefined; + await resolveNote({ media_type: 'Movie', year: '2024' }, 'Dune: Part Two.md', async (q, m, year) => { + seenYear = year; + return [HIT]; + }); + expect(seenYear).toBe('2024'); + }); +}); From b77a189848018c3da425ebd7d9091c9f8e7d345d Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 13:05:31 +0800 Subject: [PATCH 15/53] fix(watchlist): resolve 429 backoff + unique-exact-match acceptance --- packages/obsidian/src/watchlist/SyncEngine.ts | 24 ++++++----- .../src/watchlist/WatchlistController.ts | 4 +- packages/obsidian/src/watchlist/resolve.ts | 4 +- tests/watchlist-resolve.test.ts | 12 ++++++ tests/watchlist-sync-engine.test.ts | 42 ++++++++++++++++++- 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/packages/obsidian/src/watchlist/SyncEngine.ts b/packages/obsidian/src/watchlist/SyncEngine.ts index 03a599c..41f382d 100644 --- a/packages/obsidian/src/watchlist/SyncEngine.ts +++ b/packages/obsidian/src/watchlist/SyncEngine.ts @@ -30,6 +30,18 @@ export class TmdbRateLimitError extends Error { retryAfterMs: number = 2000; } +export async function withRateLimitRetry(fn: () => Promise, sleep: (ms: number) => Promise): Promise { + try { + return await fn(); + } catch (e) { + if (e instanceof TmdbRateLimitError) { + await sleep(e.retryAfterMs); + return await fn(); + } + throw e; + } +} + const ACTIVE_STATUSES = new Set(['Returning Series', 'In Production', 'Planned', 'Pilot']); function strip(s: string | undefined): string { @@ -63,17 +75,7 @@ export async function syncFolder(deps: SyncDeps, opts: SyncOptions = {}): Promis continue; } try { - let detail: any; - try { - detail = await deps.fetchDetail(ref.tmdbId, ref.isMovie); - } catch (e) { - if (e instanceof TmdbRateLimitError) { - await deps.sleep(e.retryAfterMs); - detail = await deps.fetchDetail(ref.tmdbId, ref.isMovie); - } else { - throw e; - } - } + const detail: any = await withRateLimitRetry(() => deps.fetchDetail(ref.tmdbId, ref.isMovie), deps.sleep); const record = buildRecord(detail, ref.isMovie, frontmatter); const rendered = renderNote(record, extractMyNotes(body)); report.synced++; diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts index d2cc200..44bfa5f 100644 --- a/packages/obsidian/src/watchlist/WatchlistController.ts +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -1,6 +1,6 @@ import { Notice, TFile, TFolder } from 'obsidian'; import type MediaDbPlugin from 'packages/obsidian/src/main'; -import { syncFolder, TmdbRateLimitError, type SyncDeps, type SyncReport } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { syncFolder, withRateLimitRetry, TmdbRateLimitError, type SyncDeps, type SyncReport } from 'packages/obsidian/src/watchlist/SyncEngine'; import { fetchDetail, searchTitle, type HttpJsonFn } from 'packages/obsidian/src/watchlist/tmdb'; import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; import { parseNote, noteTmdbRef, stripQuotes } from 'packages/obsidian/src/watchlist/parse'; @@ -111,7 +111,7 @@ export class WatchlistController { report.scanned++; try { const filename = note.path.split('/').pop() ?? note.path; - const result = await resolveNote(frontmatter, filename, search); + const result = await withRateLimitRetry(() => resolveNote(frontmatter, filename, search), deps.sleep); if (!result) { report.ambiguous++; deps.log(`ambiguous/no match: ${note.path}`); diff --git a/packages/obsidian/src/watchlist/resolve.ts b/packages/obsidian/src/watchlist/resolve.ts index 948fab9..a662d36 100644 --- a/packages/obsidian/src/watchlist/resolve.ts +++ b/packages/obsidian/src/watchlist/resolve.ts @@ -18,8 +18,8 @@ async function tryOne(query: string, isMovie: boolean, year: string | undefined, const results = await search(query, isMovie, year); if (results.length === 0) return null; const q = query.toLowerCase(); - const exact = results.find(r => resultTitles(r).includes(q)); - const pick = exact ?? (results.length === 1 ? results[0] : null); + 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; if (!pick) return null; return { tmdbId: String(pick.id), isMovie, matchedTitle: resultTitle(pick) }; } diff --git a/tests/watchlist-resolve.test.ts b/tests/watchlist-resolve.test.ts index 8d0424d..245f21f 100644 --- a/tests/watchlist-resolve.test.ts +++ b/tests/watchlist-resolve.test.ts @@ -34,4 +34,16 @@ describe('resolveNote', () => { }); expect(seenYear).toBe('2024'); }); + test('two exact matches (same title, different ids) → null (ambiguous)', async () => { + const RH1 = { id: 1, title: 'Robin Hood', original_title: 'Robin Hood' }; + const RH2 = { id: 2, title: 'Robin Hood', original_title: 'Robin Hood' }; + const r = await resolveNote({ media_type: 'Movie' }, 'Robin Hood.md', async () => [RH1, RH2]); + expect(r).toBeNull(); + }); + test('exact match at non-top index, unique → accepted', async () => { + const NOPE = { id: 9, title: 'Robin Hood Begins', original_title: 'Robin Hood Begins' }; + const HIT2 = { id: 10, title: 'Robin Hood', original_title: 'Robin Hood' }; + const r = await resolveNote({ media_type: 'Movie' }, 'Robin Hood.md', async () => [NOPE, HIT2]); + expect(r).toEqual({ tmdbId: '10', isMovie: true, matchedTitle: 'Robin Hood' }); + }); }); diff --git a/tests/watchlist-sync-engine.test.ts b/tests/watchlist-sync-engine.test.ts index 66e817a..6318ff8 100644 --- a/tests/watchlist-sync-engine.test.ts +++ b/tests/watchlist-sync-engine.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { syncFolder, isActive, TmdbRateLimitError, type SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { syncFolder, isActive, TmdbRateLimitError, withRateLimitRetry, type SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine'; import tvDetail from 'tests/fixtures/tmdb-tv-loki.json'; const ENDED_NOTE = `--- @@ -35,6 +35,46 @@ function makeDeps(notes: { path: string; content: string }[], detail: any = tvDe return { deps, writes, fetches }; } +describe('withRateLimitRetry', () => { + test('429 → sleep(retryAfterMs) then retry once, returns result', async () => { + const slept: number[] = []; + let calls = 0; + const fn = async () => { + calls++; + if (calls === 1) { + const e = new TmdbRateLimitError('429'); + e.retryAfterMs = 1500; + throw e; + } + return 'ok'; + }; + const result = await withRateLimitRetry(fn, async ms => { slept.push(ms); }); + expect(slept).toEqual([1500]); + expect(calls).toBe(2); + expect(result).toBe('ok'); + }); + test('non-429 error → no retry, throws immediately', async () => { + let calls = 0; + const fn = async () => { + calls++; + throw new Error('boom'); + }; + await expect(withRateLimitRetry(fn, async () => {})).rejects.toThrow('boom'); + expect(calls).toBe(1); + }); + test('429 twice → second failure propagates (retry only once)', async () => { + let calls = 0; + const fn = async () => { + calls++; + const e = new TmdbRateLimitError('429'); + e.retryAfterMs = 500; + throw e; + }; + await expect(withRateLimitRetry(fn, async () => {})).rejects.toBeInstanceOf(TmdbRateLimitError); + expect(calls).toBe(2); + }); +}); + describe('isActive tiering', () => { test('Returning Series → active', () => expect(isActive({ status: 'Returning Series' })).toBe(true)); test('Watching → active regardless of status', () => expect(isActive({ status: 'Ended', watch_status: 'Watching' })).toBe(true)); From a4ba1b8e4230dbc4715d9d7dc8db84d35f095881 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 13:24:33 +0800 Subject: [PATCH 16/53] fix(watchlist): stale-read clobber, concurrency guard, leaked timeout, quiet catch-up notices - SyncDeps.listNotes now returns paths only; new readNote(path) reads content immediately before parse/diff/write per note, closing the window where an edit made between the initial scan and a later write got silently overwritten. resolveMissingIds updated to match. - WatchlistController gains a syncing flag shared by syncNow and resolveMissingIds so overlapping invocations short-circuit instead of racing on the same vault notes. - main.ts registers cleanup for the 30s startup catch-up setTimeout so it doesn't fire after unload. - syncNow takes a quiet flag (used by the scheduled catch-up path) so zero-change scheduled syncs don't spam a Notice; manual commands are unaffected. --- packages/obsidian/src/main.ts | 5 +- packages/obsidian/src/watchlist/SyncEngine.ts | 27 ++-- .../src/watchlist/WatchlistController.ts | 138 +++++++++++------- tests/watchlist-controller.test.ts | 80 +++++++++- tests/watchlist-sync-engine.test.ts | 30 +++- 5 files changed, 208 insertions(+), 72 deletions(-) diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index 8099844..08c1ad8 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -65,7 +65,8 @@ export default class MediaDbPlugin extends Plugin { this.watchlist = new WatchlistController(this); // catch-up shortly after startup (let vault index settle), then hourly due-check - window.setTimeout(() => void this.watchlist.maybeCatchUp(), 30_000); + const catchUpTimeout = window.setTimeout(() => void this.watchlist.maybeCatchUp(), 30_000); + this.register(() => window.clearTimeout(catchUpTimeout)); this.registerInterval(window.setInterval(() => void this.watchlist.maybeCatchUp(), 3600_000)); } @@ -92,7 +93,7 @@ export default class MediaDbPlugin extends Plugin { private registerRibbonAndFileMenu(): void { const ribbonIconEl = this.addRibbonIcon('database', 'Add new Media DB entry', () => this.entryHelper.createEntryWithAdvancedSearchModal()); - ribbonIconEl.addClass('obsidian-media-db-plugin-ribbon-class'); + ribbonIconEl.addClass('media-db-sync-ribbon-class'); this.registerEvent( this.app.workspace.on('file-menu', (menu, file) => { diff --git a/packages/obsidian/src/watchlist/SyncEngine.ts b/packages/obsidian/src/watchlist/SyncEngine.ts index 41f382d..34025d5 100644 --- a/packages/obsidian/src/watchlist/SyncEngine.ts +++ b/packages/obsidian/src/watchlist/SyncEngine.ts @@ -3,7 +3,8 @@ import { buildRecord } from 'packages/obsidian/src/watchlist/build'; import { renderNote } from 'packages/obsidian/src/watchlist/render'; export interface SyncDeps { - listNotes(): Promise<{ path: string; content: string }[]>; + listNotes(): Promise<{ path: string }[]>; + readNote(path: string): Promise; writeNote(path: string, content: string): Promise; fetchDetail(tmdbId: string, isMovie: boolean): Promise; sleep(ms: number): Promise; @@ -64,17 +65,19 @@ export async function syncFolder(deps: SyncDeps, opts: SyncOptions = {}): Promis const notes = await deps.listNotes(); for (const note of notes) { report.scanned++; - const { frontmatter, body } = parseNote(note.content); - const ref = noteTmdbRef(frontmatter); - if (!ref) { - report.skippedNoId++; - continue; - } - if (!opts.full && !isActive(frontmatter)) { - report.skippedStatic++; - continue; - } try { + // Read immediately before parse/diff so mid-sync edits aren't clobbered by a stale snapshot. + const content = await deps.readNote(note.path); + const { frontmatter, body } = parseNote(content); + const ref = noteTmdbRef(frontmatter); + if (!ref) { + report.skippedNoId++; + continue; + } + if (!opts.full && !isActive(frontmatter)) { + report.skippedStatic++; + continue; + } const detail: any = await withRateLimitRetry(() => deps.fetchDetail(ref.tmdbId, ref.isMovie), deps.sleep); const record = buildRecord(detail, ref.isMovie, frontmatter); const rendered = renderNote(record, extractMyNotes(body)); @@ -82,7 +85,7 @@ export async function syncFolder(deps: SyncDeps, opts: SyncOptions = {}): Promis if (strip(frontmatter['watch_status']) === 'Watched' && record.watchStatus === 'Unwatched') { report.flipped.push(note.path); } - if (rendered !== note.content) { + if (rendered !== content) { report.written++; if (!opts.dryRun) await deps.writeNote(note.path, rendered); deps.log(`${opts.dryRun ? '[dry] ' : ''}updated ${note.path}`); diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts index 44bfa5f..5928377 100644 --- a/packages/obsidian/src/watchlist/WatchlistController.ts +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -25,7 +25,13 @@ function patchFrontmatter(content: string, tmdbId: string, mediaType: string): s return `---\ntype: watchlist_item\n${insert}\n---\n\n` + content; } +export function shouldNotifySync(quiet: boolean, written: number, errorCount: number): boolean { + return !quiet || written > 0 || errorCount > 0; +} + export class WatchlistController { + private syncing = false; + constructor(private plugin: MediaDbPlugin) {} private getKey(): string { @@ -40,8 +46,8 @@ export class WatchlistController { const res = await obsidianFetch(new Request(url, { headers })); if (res.status === 429) { const err = new TmdbRateLimitError('TMDB 429'); - const ra = Number(res.headers.get('retry-after') ?? 2); - err.retryAfterMs = ra * 1000; + const ra = Number(res.headers.get('retry-after')); + err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000; throw err; } if (res.status !== 200) throw new Error(`TMDB ${res.status} for ${url}`); @@ -57,9 +63,12 @@ export class WatchlistController { listNotes: async () => { if (!(folder instanceof TFolder)) throw new Error(`Watchlist folder not found: ${this.plugin.settings.watchlistFolder}`); const files = folder.children.filter((f): f is TFile => f instanceof TFile && f.extension === 'md' && !f.name.startsWith('_')); - const out: { path: string; content: string }[] = []; - for (const f of files) out.push({ path: f.path, content: await app.vault.read(f) }); - return out; + return files.map(f => ({ path: f.path })); + }, + readNote: async path => { + const f = app.vault.getAbstractFileByPath(path); + if (!(f instanceof TFile)) throw new Error(`Watchlist note not found: ${path}`); + return await app.vault.read(f); }, writeNote: async (path, content) => { const f = app.vault.getAbstractFileByPath(path); @@ -71,19 +80,30 @@ export class WatchlistController { }; } - async syncNow(full: boolean, dryRun = false): Promise { - const key = this.getKey(); - const report = await syncFolder(this.makeDeps(key), { full, dryRun }); - const mode = dryRun ? 'DRY-RUN ' : ''; - new Notice( - `Watchlist ${mode}sync: ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped to Unwatched` + - (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), - ); - if (!dryRun) { - this.plugin.settings.watchlistLastSync = Date.now(); - await this.plugin.saveSettings(); + async syncNow(full: boolean, dryRun = false, quiet = false): Promise { + if (this.syncing) { + new Notice('Watchlist sync already running'); + return { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, flipped: [], errors: [] }; + } + this.syncing = true; + try { + const key = this.getKey(); + const report = await syncFolder(this.makeDeps(key), { full, dryRun }); + const mode = dryRun ? 'DRY-RUN ' : ''; + if (shouldNotifySync(quiet, report.written, report.errors.length)) { + new Notice( + `Watchlist ${mode}sync: ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped to Unwatched` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + } + if (!dryRun) { + this.plugin.settings.watchlistLastSync = Date.now(); + await this.plugin.saveSettings(); + } + return report; + } finally { + this.syncing = false; } - return report; } async maybeCatchUp(): Promise { @@ -91,47 +111,57 @@ export class WatchlistController { if (!s.watchlistEnabled) return; const due = s.watchlistLastSync + s.watchlistSyncIntervalHours * 3600_000; if (Date.now() >= due) { - await this.syncNow(false).catch(e => console.error('[media-db-sync] scheduled sync failed', e)); + await this.syncNow(false, false, true).catch(e => console.error('[media-db-sync] scheduled sync failed', e)); } } async resolveMissingIds(dryRun = false): Promise { - const key = this.getKey(); - const deps = this.makeDeps(key); - const http = this.makeHttp(); - const search = (q: string, isMovie: boolean, year?: string) => searchTitle(http, key, q, isMovie, year); - - const report: ResolveReport = { scanned: 0, resolved: 0, ambiguous: 0, errors: [] }; - const notes = await deps.listNotes(); - for (const note of notes) { - const { frontmatter } = parseNote(note.content); - if (noteTmdbRef(frontmatter)) continue; - const type = stripQuotes(frontmatter['type']); - if (type && type !== 'watchlist_item') continue; // skip type: list dashboards etc. - report.scanned++; - try { - const filename = note.path.split('/').pop() ?? note.path; - const result = await withRateLimitRetry(() => resolveNote(frontmatter, filename, search), deps.sleep); - if (!result) { - report.ambiguous++; - deps.log(`ambiguous/no match: ${note.path}`); - } else { - report.resolved++; - const mediaType = result.isMovie ? 'Movie' : 'TV Series'; - deps.log(`${dryRun ? '[dry] ' : ''}resolved ${note.path} -> tmdb_id ${result.tmdbId} (${result.matchedTitle})`); - if (!dryRun) await deps.writeNote(note.path, patchFrontmatter(note.content, result.tmdbId, mediaType)); - } - await deps.sleep(250); - } catch (e) { - report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) }); - deps.log(`ERROR ${note.path}: ${String(e)}`); - } + if (this.syncing) { + new Notice('Watchlist sync already running'); + return { scanned: 0, resolved: 0, ambiguous: 0, errors: [] }; + } + this.syncing = true; + try { + const key = this.getKey(); + const deps = this.makeDeps(key); + const http = this.makeHttp(); + const search = (q: string, isMovie: boolean, year?: string) => searchTitle(http, key, q, isMovie, year); + + const report: ResolveReport = { scanned: 0, resolved: 0, ambiguous: 0, errors: [] }; + const notes = await deps.listNotes(); + for (const note of notes) { + const content = await deps.readNote(note.path); + const { frontmatter } = parseNote(content); + if (noteTmdbRef(frontmatter)) continue; + const type = stripQuotes(frontmatter['type']); + if (type && type !== 'watchlist_item') continue; // skip type: list dashboards etc. + report.scanned++; + try { + const filename = note.path.split('/').pop() ?? note.path; + const result = await withRateLimitRetry(() => resolveNote(frontmatter, filename, search), deps.sleep); + if (!result) { + report.ambiguous++; + deps.log(`ambiguous/no match: ${note.path}`); + } else { + report.resolved++; + const mediaType = result.isMovie ? 'Movie' : 'TV Series'; + deps.log(`${dryRun ? '[dry] ' : ''}resolved ${note.path} -> tmdb_id ${result.tmdbId} (${result.matchedTitle})`); + if (!dryRun) await deps.writeNote(note.path, patchFrontmatter(content, result.tmdbId, mediaType)); + } + await deps.sleep(250); + } catch (e) { + report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) }); + deps.log(`ERROR ${note.path}: ${String(e)}`); + } + } + const mode = dryRun ? 'DRY-RUN ' : ''; + new Notice( + `Watchlist ${mode}resolve: ${report.scanned} scanned, ${report.resolved} resolved, ${report.ambiguous} ambiguous/no match` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + return report; + } finally { + this.syncing = false; } - const mode = dryRun ? 'DRY-RUN ' : ''; - new Notice( - `Watchlist ${mode}resolve: ${report.scanned} scanned, ${report.resolved} resolved, ${report.ambiguous} ambiguous/no match` + - (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), - ); - return report; } } diff --git a/tests/watchlist-controller.test.ts b/tests/watchlist-controller.test.ts index e3efb79..865e671 100644 --- a/tests/watchlist-controller.test.ts +++ b/tests/watchlist-controller.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { WatchlistController } from 'packages/obsidian/src/watchlist/WatchlistController'; +import { WatchlistController, shouldNotifySync } from 'packages/obsidian/src/watchlist/WatchlistController'; +import type { SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine'; function fakePlugin(overrides: Partial<{ enabled: boolean; last: number; hours: number }> = {}) { return { @@ -38,3 +39,80 @@ describe('maybeCatchUp', () => { expect(called).toBe(false); }); }); + +function deferredDeps(): { deps: SyncDeps; listNotesCalls: () => number; release: () => void } { + let listNotesCalls = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const deps: SyncDeps = { + listNotes: async () => { listNotesCalls++; await gate; return []; }, + readNote: async () => '', + writeNote: async () => {}, + fetchDetail: async () => ({}), + sleep: async () => {}, + log: () => {}, + }; + return { deps, listNotesCalls: () => listNotesCalls, release }; +} + +describe('concurrency guard', () => { + test('overlapping syncNow calls: second short-circuits while first is in flight', async () => { + const c = new WatchlistController(fakePlugin()); + const { deps, listNotesCalls, release } = deferredDeps(); + (c as any).getKey = () => 'fake-key'; + (c as any).makeDeps = () => deps; + + const first = c.syncNow(false); + const second = await c.syncNow(false); + + expect(listNotesCalls()).toBe(1); + expect(second.scanned).toBe(0); + expect(second.written).toBe(0); + + release(); + const firstResult = await first; + expect(firstResult.scanned).toBe(0); + }); + + test('syncNow in flight blocks resolveMissingIds (shared guard)', async () => { + const c = new WatchlistController(fakePlugin()); + const { deps, release } = deferredDeps(); + (c as any).getKey = () => 'fake-key'; + (c as any).makeDeps = () => deps; + + const first = c.syncNow(false); + const resolveResult = await c.resolveMissingIds(); + + expect(resolveResult.scanned).toBe(0); + expect(resolveResult.resolved).toBe(0); + + release(); + await first; + }); + + test('flag resets after completion → next call runs normally', async () => { + const c = new WatchlistController(fakePlugin()); + (c as any).getKey = () => 'fake-key'; + let listNotesCalls = 0; + (c as any).makeDeps = () => ({ + listNotes: async () => { listNotesCalls++; return []; }, + readNote: async () => '', + writeNote: async () => {}, + fetchDetail: async () => ({}), + sleep: async () => {}, + log: () => {}, + }); + + await c.syncNow(false); + await c.syncNow(false); + + expect(listNotesCalls).toBe(2); + }); +}); + +describe('shouldNotifySync', () => { + test('non-quiet → always notifies', () => expect(shouldNotifySync(false, 0, 0)).toBe(true)); + test('quiet + no changes + no errors → suppressed', () => expect(shouldNotifySync(true, 0, 0)).toBe(false)); + test('quiet + written>0 → notifies', () => expect(shouldNotifySync(true, 3, 0)).toBe(true)); + test('quiet + errors>0 → notifies', () => expect(shouldNotifySync(true, 0, 2)).toBe(true)); +}); diff --git a/tests/watchlist-sync-engine.test.ts b/tests/watchlist-sync-engine.test.ts index 6318ff8..f0cb251 100644 --- a/tests/watchlist-sync-engine.test.ts +++ b/tests/watchlist-sync-engine.test.ts @@ -23,16 +23,18 @@ keep me const AIRING_NOTE = ENDED_NOTE.replace('status: Ended', 'status: Returning Series').replace('last_air_date: 2023-11-09', 'last_air_date: 2023-10-01'); function makeDeps(notes: { path: string; content: string }[], detail: any = tvDetail) { + const contents = new Map(notes.map(n => [n.path, n.content])); const writes: { path: string; content: string }[] = []; const fetches: string[] = []; const deps: SyncDeps = { - listNotes: async () => notes, + listNotes: async () => notes.map(n => ({ path: n.path })), + readNote: async path => contents.get(path)!, writeNote: async (path, content) => { writes.push({ path, content }); }, fetchDetail: async (id) => { fetches.push(id); return detail; }, sleep: async () => {}, log: () => {}, }; - return { deps, writes, fetches }; + return { deps, writes, fetches, contents }; } describe('withRateLimitRetry', () => { @@ -133,7 +135,8 @@ describe('syncFolder', () => { let calls = 0; const slept: number[] = []; const deps: SyncDeps = { - listNotes: async () => [{ path: 'Loki.md', content: AIRING_NOTE }], + listNotes: async () => [{ path: 'Loki.md' }], + readNote: async () => AIRING_NOTE, writeNote: async () => {}, fetchDetail: async () => { calls++; @@ -160,4 +163,25 @@ describe('syncFolder', () => { expect(report.errors[0].path).toBe('Bad.md'); expect(report.synced).toBe(1); }); + test('stale-read guard: content edited mid-sync is re-read fresh, not clobbered by early snapshot', async () => { + const { deps, writes, contents } = makeDeps([ + { path: 'A.md', content: AIRING_NOTE }, + { path: 'B.md', content: AIRING_NOTE }, + ]); + const originalFetch = deps.fetchDetail; + let calls = 0; + deps.fetchDetail = async (id, isMovie) => { + calls++; + if (calls === 1) { + // simulate the user editing B.md's "My Notes" while A.md is still mid-sync, + // i.e. after listNotes() ran but before B.md is actually processed. + contents.set('B.md', AIRING_NOTE.replace('keep me', 'edited during sync')); + } + return originalFetch(id, isMovie); + }; + await syncFolder(deps); + const bWrite = writes.find(w => w.path === 'B.md'); + expect(bWrite?.content).toContain('edited during sync'); + expect(bWrite?.content).not.toContain('keep me'); + }); }); From 699bbf3a9fa7c6e174f41eab01e5c1d7e4e92bf0 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 13:24:41 +0800 Subject: [PATCH 17/53] chore: finish rebrand from upstream obsidian-media-db-plugin package.json author/description now match manifest.json, release workflow plugin name and repo-automation.config.json repo ref no longer point at the upstream project. --- .github/workflows/release.yml | 2 +- package.json | 4 ++-- repo-automation.config.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7202e7..d092d66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 env: - PLUGIN_NAME: obsidian-media-db-plugin # Change this to the name of your plugin-id folder + PLUGIN_NAME: media-db-sync # Change this to the name of your plugin-id folder jobs: build: diff --git a/package.json b/package.json index 5f963a3..379e53e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "media-db-sync", "version": "0.1.0", - "description": "A plugin that can query multiple APIs for movies, series, anime, manga, books, comics, games, music and wiki articles, and import them into your vault.", + "description": "Media library with automatic metadata enrichment, periodic sync, and watch-status automation. Fork of Media DB by Moritz Jung.", "main": "main.js", "scripts": { "dev": "vite build --watch --mode development", @@ -18,7 +18,7 @@ "release": "lemons-automation release" }, "keywords": [], - "author": "Moritz Jung", + "author": "Afiq Zudin Hadi", "license": "GPL-3.0", "devDependencies": { "@happy-dom/global-registrator": "^20.10.4", diff --git a/repo-automation.config.json b/repo-automation.config.json index 560b511..4095a4b 100644 --- a/repo-automation.config.json +++ b/repo-automation.config.json @@ -1,6 +1,6 @@ { "devBranch": "master", "releaseBranch": "release", - "github": "https://github.com/mProjectsCode/obsidian-media-db-plugin", + "github": "https://github.com/afiqzudinhadi/obsidian-media-db-sync", "preconditions": ["bun run typecheck", "bun run format", "bun run lint:fix", "bun run test"] } From b0f3dce76db908ba8d6bb4f5903ffc84eb9c8c78 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 14:00:20 +0800 Subject: [PATCH 18/53] fix: null-guard loadData on fresh install (no data.json crashed onload) --- packages/obsidian/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index 08c1ad8..b80d72d 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -213,7 +213,7 @@ export default class MediaDbPlugin extends Plugin { } async loadSettings(): Promise { - const diskSettings = (await this.loadData()) as Record; + const diskSettings = ((await this.loadData()) ?? {}) as Record; const defaultSettings: MediaDbPluginSettings = getDefaultSettings(this); const loadedSettings = Object.assign({}, defaultSettings, diskSettings) as MediaDbPluginSettings; From 2c663568ca9bc957af9239c98bc483cbc67a646e Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 14:32:42 +0800 Subject: [PATCH 19/53] feat(watchlist): preserve existing TV crew fields until per-episode enrichment --- packages/obsidian/src/watchlist/build.ts | 18 ++++++++++++++--- tests/watchlist-build.test.ts | 25 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/obsidian/src/watchlist/build.ts b/packages/obsidian/src/watchlist/build.ts index e399f7b..bc78de9 100644 --- a/packages/obsidian/src/watchlist/build.ts +++ b/packages/obsidian/src/watchlist/build.ts @@ -18,6 +18,13 @@ function langName(details: TmdbDetail): string { return LANG_FALLBACK[code] ?? code; } +function prevCrewList(prev: Record, key: string): string[] { + return stripQuotes(prev[key]) + .split(/,\s*/) + .map(s => s.trim()) + .filter(Boolean); +} + function crewNames(crew: any[], jobs: Set): string[] { const seen = new Set(); const out: string[] = []; @@ -94,9 +101,14 @@ export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record< const agg = details.aggregate_credits ?? {}; cast = (agg.cast ?? []).slice(0, 12).map((c: any) => c.name); const createdBy: string[] = (details.created_by ?? []).map((c: any) => c.name); - director = createdBy; // series: creators (latest-episode director needs extra call — Phase 3) - writer = createdBy; - producer = []; + // series: creators (latest-episode director needs extra call — Phase 3) + // prev user-set crew wins over created_by fallback until per-episode enrichment lands + const prevDirector = prevCrewList(prev, 'director'); + const prevWriter = prevCrewList(prev, 'writer'); + const prevProducer = prevCrewList(prev, 'producer'); + director = prevDirector.length ? prevDirector : createdBy; + writer = prevWriter.length ? prevWriter : createdBy; + producer = prevProducer.length ? prevProducer : []; title = details.name ?? ''; originalTitle = details.original_name ?? ''; mediaType = 'TV Series'; diff --git a/tests/watchlist-build.test.ts b/tests/watchlist-build.test.ts index 44d3780..3414565 100644 --- a/tests/watchlist-build.test.ts +++ b/tests/watchlist-build.test.ts @@ -111,6 +111,31 @@ describe('watch-status rule (TV)', () => { }); }); +describe('TV crew preservation', () => { + test('TV + prev crew present → prev wins over created_by', () => { + const prev = { + director: '"Aaron Moorhead, Justin Benson"', + writer: 'Eric Martin', + producer: '"Rachel Alter, Tommy Turtle"', + }; + const r = buildRecord(tvDetail, false, prev); + expect(r.director).toEqual(['Aaron Moorhead', 'Justin Benson']); + expect(r.writer).toEqual(['Eric Martin']); + expect(r.producer).toEqual(['Rachel Alter', 'Tommy Turtle']); + }); + test('TV + empty prev crew → falls back to created_by', () => { + const r = buildRecord(tvDetail, false, EMPTY_PREV); + expect(r.director).toEqual(['Michael Waldron']); + expect(r.writer).toEqual(['Michael Waldron']); + expect(r.producer).toEqual([]); + }); + test('Movie + prev crew present → prev ignored, TMDB credits win', () => { + const prev = { director: 'Someone Else' }; + const r = buildRecord(movieDetail, true, prev); + expect(r.director).toEqual(['Denis Villeneuve']); + }); +}); + describe('eng_name derivation', () => { test('non-Latin original → engName = localized title', () => { const jp = { ...movieDetail, title: 'A Silent Voice: The Movie', original_title: '映画 聲の形' }; From fb3f52e4b04c3d7c0d1820ca435165c7142ee94f Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 14:59:24 +0800 Subject: [PATCH 20/53] fix(watchlist): preserve custom body sections through sync rewrite renderNote wiped any ## heading not owned by the renderer (Synopsis, Cast, Links, My Notes) on every sync, destroying user content like ## Collection graph-link sections. extractCustomSections now pulls non-owned sections out of the existing body before rewrite, and renderNote re-emits them verbatim, in original order, between Links and My Notes. --- packages/obsidian/src/watchlist/SyncEngine.ts | 4 +- packages/obsidian/src/watchlist/parse.ts | 31 +++++++++++++++ packages/obsidian/src/watchlist/render.ts | 4 +- tests/watchlist-parse.test.ts | 31 ++++++++++++++- tests/watchlist-render.test.ts | 25 ++++++++++++ tests/watchlist-sync-engine.test.ts | 39 +++++++++++++++++++ 6 files changed, 130 insertions(+), 4 deletions(-) diff --git a/packages/obsidian/src/watchlist/SyncEngine.ts b/packages/obsidian/src/watchlist/SyncEngine.ts index 34025d5..c5462b7 100644 --- a/packages/obsidian/src/watchlist/SyncEngine.ts +++ b/packages/obsidian/src/watchlist/SyncEngine.ts @@ -1,4 +1,4 @@ -import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; +import { parseNote, extractMyNotes, extractCustomSections, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; import { buildRecord } from 'packages/obsidian/src/watchlist/build'; import { renderNote } from 'packages/obsidian/src/watchlist/render'; @@ -80,7 +80,7 @@ export async function syncFolder(deps: SyncDeps, opts: SyncOptions = {}): Promis } const detail: any = await withRateLimitRetry(() => deps.fetchDetail(ref.tmdbId, ref.isMovie), deps.sleep); const record = buildRecord(detail, ref.isMovie, frontmatter); - const rendered = renderNote(record, extractMyNotes(body)); + const rendered = renderNote(record, extractMyNotes(body), extractCustomSections(body)); report.synced++; if (strip(frontmatter['watch_status']) === 'Watched' && record.watchStatus === 'Unwatched') { report.flipped.push(note.path); diff --git a/packages/obsidian/src/watchlist/parse.ts b/packages/obsidian/src/watchlist/parse.ts index 041e484..401a281 100644 --- a/packages/obsidian/src/watchlist/parse.ts +++ b/packages/obsidian/src/watchlist/parse.ts @@ -22,6 +22,37 @@ export function extractMyNotes(body: string): string { return m ? m[1].trim() : ''; } +const OWNED_HEADINGS = new Set(['Synopsis', 'Cast', 'Links', 'My Notes']); + +export interface CustomSection { + heading: string; + content: string; +} + +export function extractCustomSections(body: string): CustomSection[] { + const lines = (body ?? '').split('\n'); + const sections: CustomSection[] = []; + let heading: string | null = null; + let buf: string[] = []; + const flush = (): void => { + if (heading === null) return; + const content = buf.join('\n').replace(/(\n\s*)+$/, ''); + if (!OWNED_HEADINGS.has(heading)) sections.push({ heading, content }); + }; + for (const line of lines) { + const m = /^##\s+(.*)$/.exec(line); + if (m) { + flush(); + heading = m[1].trim(); + buf = []; + } else if (heading !== null) { + buf.push(line); + } + } + flush(); + return sections; +} + export function stripQuotes(s: string | undefined): string { return (s ?? '').trim().replace(/^"|"$/g, ''); } diff --git a/packages/obsidian/src/watchlist/render.ts b/packages/obsidian/src/watchlist/render.ts index 1d63e51..186a50d 100644 --- a/packages/obsidian/src/watchlist/render.ts +++ b/packages/obsidian/src/watchlist/render.ts @@ -1,9 +1,10 @@ import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; +import type { CustomSection } from 'packages/obsidian/src/watchlist/parse'; const CATEGORY_TAG: Record = { Movie: 'movie', Series: 'series', Anime: 'anime' }; -export function renderNote(r: WatchlistRecord, myNotes: string): string { +export function renderNote(r: WatchlistRecord, myNotes: string, customSections: CustomSection[] = []): string { const tag = CATEGORY_TAG[r.category]; const stars = r.ratingStars; const fm = [ @@ -67,6 +68,7 @@ export function renderNote(r: WatchlistRecord, myNotes: string): string { if (r.homepage) links.push(`- [Homepage](${r.homepage})`); if (r.notionUrl) links.push(`- [Original Notion entry](${r.notionUrl})`); 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'); diff --git a/tests/watchlist-parse.test.ts b/tests/watchlist-parse.test.ts index 27c0f70..df0cc24 100644 --- a/tests/watchlist-parse.test.ts +++ b/tests/watchlist-parse.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse'; +import { parseNote, extractMyNotes, noteTmdbRef, extractCustomSections } from 'packages/obsidian/src/watchlist/parse'; const NOTE = `--- type: watchlist_item @@ -55,3 +55,32 @@ describe('noteTmdbRef', () => { expect(noteTmdbRef({ type: 'list' })).toBeNull(); }); }); + +describe('extractCustomSections', () => { + test('extracts non-owned section', () => { + const body = `\n# Loki\n\n## Links\n- [IMDb](x)\n\n## Collection\nPart of [[Movies]]\n\n## My Notes\n\ngreat finale\n`; + expect(extractCustomSections(body)).toEqual([{ heading: 'Collection', content: 'Part of [[Movies]]' }]); + }); + test('owned headings excluded (Synopsis, Cast, Links, My Notes)', () => { + const body = `\n## Synopsis\nblah\n\n## Cast\nA, B\n\n## Links\n- x\n\n## My Notes\n\nnote\n`; + expect(extractCustomSections(body)).toEqual([]); + }); + test('two custom sections keep order', () => { + const body = `\n## Collection\nPart of [[Movies]]\n\n## Rewatch Log\n- 2024-01-01\n- 2025-02-02\n\n## My Notes\n\nnote\n`; + expect(extractCustomSections(body)).toEqual([ + { heading: 'Collection', content: 'Part of [[Movies]]' }, + { heading: 'Rewatch Log', content: '- 2024-01-01\n- 2025-02-02' }, + ]); + }); + test('preserves internal blank lines, trims trailing', () => { + const body = `\n## Collection\nline one\n\nline two\n\n\n## My Notes\n\nnote\n`; + expect(extractCustomSections(body)).toEqual([{ heading: 'Collection', content: 'line one\n\nline two' }]); + }); + test('no custom sections → empty array', () => { + const body = `\n# Loki\n\n## Links\n- x\n\n## My Notes\n\nnote\n`; + expect(extractCustomSections(body)).toEqual([]); + }); + test('empty/missing body → empty array', () => { + expect(extractCustomSections('')).toEqual([]); + }); +}); diff --git a/tests/watchlist-render.test.ts b/tests/watchlist-render.test.ts index 5555ad3..9fb2e73 100644 --- a/tests/watchlist-render.test.ts +++ b/tests/watchlist-render.test.ts @@ -37,3 +37,28 @@ describe('renderNote golden', () => { expect(renderNote(r, '')).not.toContain('**Rating:**'); }); }); + +describe('renderNote custom sections', () => { + test('no custom sections → output unchanged (golden)', () => { + const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-series.md'), 'utf-8'); + expect(renderNote(LOKI, '', [])).toBe(expected); + }); + test('single custom section placed after Links, before My Notes', () => { + const out = renderNote(LOKI, '', [{ heading: 'Collection', content: 'Part of [[Movies]]' }]); + const linksIdx = out.indexOf('## Links'); + const collectionIdx = out.indexOf('## Collection'); + const myNotesIdx = out.indexOf('## My Notes'); + expect(linksIdx).toBeGreaterThan(-1); + expect(collectionIdx).toBeGreaterThan(linksIdx); + expect(myNotesIdx).toBeGreaterThan(collectionIdx); + expect(out).toContain('## Collection\nPart of [[Movies]]\n'); + }); + test('multiple custom sections keep relative order', () => { + const out = renderNote(LOKI, '', [ + { heading: 'Collection', content: 'Part of [[Movies]]' }, + { heading: 'Rewatch Log', content: '- 2024-01-01' }, + ]); + expect(out.indexOf('## Collection')).toBeLessThan(out.indexOf('## Rewatch Log')); + expect(out.indexOf('## Rewatch Log')).toBeLessThan(out.indexOf('## My Notes')); + }); +}); diff --git a/tests/watchlist-sync-engine.test.ts b/tests/watchlist-sync-engine.test.ts index f0cb251..5bbed62 100644 --- a/tests/watchlist-sync-engine.test.ts +++ b/tests/watchlist-sync-engine.test.ts @@ -22,6 +22,13 @@ keep me const AIRING_NOTE = ENDED_NOTE.replace('status: Ended', 'status: Returning Series').replace('last_air_date: 2023-11-09', 'last_air_date: 2023-10-01'); +const NOTE_WITH_COLLECTION = ENDED_NOTE.replace('## My Notes', '## Collection\nPart of [[Movies]]\n\n## My Notes'); + +const NOTE_WITH_TWO_CUSTOM_SECTIONS = ENDED_NOTE.replace( + '## My Notes', + '## Collection\nPart of [[Movies]]\n\n## Rewatch Log\n- 2024-01-01\n- 2025-02-02\n\n## My Notes', +); + function makeDeps(notes: { path: string; content: string }[], detail: any = tvDetail) { const contents = new Map(notes.map(n => [n.path, n.content])); const writes: { path: string; content: string }[] = []; @@ -163,6 +170,38 @@ describe('syncFolder', () => { expect(report.errors[0].path).toBe('Bad.md'); expect(report.synced).toBe(1); }); + test('custom section round-trips through rewrite, positioned after Links and before My Notes', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: NOTE_WITH_COLLECTION }]); + await syncFolder(deps, { full: true }); + const out = writes[0].content; + expect(out).toContain('## Collection\nPart of [[Movies]]\n'); + const linksIdx = out.indexOf('## Links'); + const collectionIdx = out.indexOf('## Collection'); + const myNotesIdx = out.indexOf('## My Notes'); + expect(collectionIdx).toBeGreaterThan(linksIdx); + expect(myNotesIdx).toBeGreaterThan(collectionIdx); + }); + test('two custom sections keep relative order through rewrite', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: NOTE_WITH_TWO_CUSTOM_SECTIONS }]); + await syncFolder(deps, { full: true }); + const out = writes[0].content; + expect(out.indexOf('## Collection')).toBeLessThan(out.indexOf('## Rewatch Log')); + expect(out.indexOf('## Rewatch Log')).toBeLessThan(out.indexOf('## My Notes')); + }); + test('idempotence: custom section stable across two sync passes (render→parse→extract→render byte-identical)', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: NOTE_WITH_COLLECTION }]); + await syncFolder(deps, { full: true }); + const rendered = writes[0].content; + const second = makeDeps([{ path: 'Loki.md', content: rendered }]); + const report = await syncFolder(second.deps, { full: true }); + expect(second.writes.length).toBe(0); + expect(report.written).toBe(0); + }); + test('golden: note with no custom sections unaffected by custom-section plumbing', async () => { + const { deps, writes } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]); + await syncFolder(deps, { full: true }); + expect(writes[0].content).not.toContain('## Collection'); + }); test('stale-read guard: content edited mid-sync is re-read fresh, not clobbered by early snapshot', async () => { const { deps, writes, contents } = makeDeps([ { path: 'A.md', content: AIRING_NOTE }, From e0e0462836062360ec3bd07e20718dc5422563a6 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 15:00:39 +0800 Subject: [PATCH 21/53] fix(watchlist): TV country full name + tmdb_rating precision rounding TMDB's origin_country returns ISO codes (KR), not full names, causing a vault regression from South Korea to KR. buildRecord now prefers production_countries[0].name, falling back to the raw origin_country code only when absent. TMDB also now returns vote_average at full precision (6.537), causing mass diff noise against the vault's 1-decimal convention and spurious rewrites on precision drift alone. tmdb_rating is now rounded to 1 decimal place. Loki fixture updated to realistic TMDB shapes (ISO country code + production_countries) to catch this regression going forward. --- packages/obsidian/src/watchlist/build.ts | 7 +++--- tests/fixtures/tmdb-tv-loki.json | 8 ++++++- tests/watchlist-build.test.ts | 27 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/obsidian/src/watchlist/build.ts b/packages/obsidian/src/watchlist/build.ts index bc78de9..33f89d0 100644 --- a/packages/obsidian/src/watchlist/build.ts +++ b/packages/obsidian/src/watchlist/build.ts @@ -117,8 +117,9 @@ export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record< runtime = rt.length > 0 ? rt[0] : null; status = details.status ?? ''; contentRating = usCertFromContentRatings(details.content_ratings ?? {}); - const countries: string[] = details.origin_country ?? []; - country = countries[0] ?? ''; + const prodCountries: any[] = details.production_countries ?? []; + const originCountries: string[] = details.origin_country ?? []; + country = prodCountries[0]?.name ?? originCountries[0] ?? ''; seasons = details.number_of_seasons ?? null; episodes = details.number_of_episodes ?? null; vod = (details.networks ?? []).map((n: any) => n.name); @@ -166,7 +167,7 @@ export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record< watchStatus, rating, ratingStars, year: yearDisp, runtime, seasons, episodes, vod, genre: genres, status, language, country, director, writer, producer, contentRating, - tmdbRating: details.vote_average ?? null, tmdbId: String(details.id), + tmdbRating: details.vote_average != null ? Math.round(details.vote_average * 10) / 10 : null, tmdbId: String(details.id), imdbId, releaseDate, lastAirDate, nextAirDate, lastEpisode, upcomingEpisode: upEpisode, poster, trailer, homepage: details.homepage ?? '', imdbPage: imdbId ? `https://www.imdb.com/title/${imdbId}/` : '', diff --git a/tests/fixtures/tmdb-tv-loki.json b/tests/fixtures/tmdb-tv-loki.json index 0e6085a..3ab5b80 100644 --- a/tests/fixtures/tmdb-tv-loki.json +++ b/tests/fixtures/tmdb-tv-loki.json @@ -33,7 +33,13 @@ } ], "origin_country": [ - "United States of America" + "US" + ], + "production_countries": [ + { + "iso_3166_1": "US", + "name": "United States of America" + } ], "created_by": [ { diff --git a/tests/watchlist-build.test.ts b/tests/watchlist-build.test.ts index 3414565..53827f8 100644 --- a/tests/watchlist-build.test.ts +++ b/tests/watchlist-build.test.ts @@ -136,6 +136,33 @@ describe('TV crew preservation', () => { }); }); +describe('TV country derivation', () => { + test('prefers production_countries full name over raw origin_country code', () => { + const r = buildRecord(tvDetail, false, EMPTY_PREV); + expect(r.country).toBe('United States of America'); + }); + test('no production_countries → falls back to raw origin_country code', () => { + const { production_countries, ...noProdCountries } = tvDetail as any; + const r = buildRecord(noProdCountries, false, EMPTY_PREV); + expect(r.country).toBe('US'); + }); +}); + +describe('tmdb_rating rounding', () => { + test('6.537 → 6.5', () => { + expect(buildRecord({ ...tvDetail, vote_average: 6.537 }, false, EMPTY_PREV).tmdbRating).toBe(6.5); + }); + test('7.854 → 7.9', () => { + expect(buildRecord({ ...tvDetail, vote_average: 7.854 }, false, EMPTY_PREV).tmdbRating).toBe(7.9); + }); + test('8.2 → 8.2 (unaffected)', () => { + expect(buildRecord(tvDetail, false, EMPTY_PREV).tmdbRating).toBe(8.2); + }); + test('null → null', () => { + expect(buildRecord({ ...tvDetail, vote_average: null }, false, EMPTY_PREV).tmdbRating).toBeNull(); + }); +}); + describe('eng_name derivation', () => { test('non-Latin original → engName = localized title', () => { const jp = { ...movieDetail, title: 'A Silent Voice: The Movie', original_title: '映画 聲の形' }; From a5ebb6f088beb61fbbc46c72c3774dce31621342 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 15:20:43 +0800 Subject: [PATCH 22/53] fix(watchlist): preserve prev country when TMDB tv lacks production_countries Co-Authored-By: Claude Fable 5 --- packages/obsidian/src/watchlist/build.ts | 15 ++++++++++++++- tests/watchlist-build.test.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/obsidian/src/watchlist/build.ts b/packages/obsidian/src/watchlist/build.ts index 33f89d0..bc6d55e 100644 --- a/packages/obsidian/src/watchlist/build.ts +++ b/packages/obsidian/src/watchlist/build.ts @@ -63,6 +63,10 @@ function pickTrailer(videos: TmdbDetail): string { return ''; } +function isBareIsoCode(s: string): boolean { + return /^[A-Z]{2}$/.test(s); +} + export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record): WatchlistRecord { const genres: string[] = (details.genres ?? []).map((g: any) => g.name); const language = langName(details); @@ -119,7 +123,16 @@ export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record< contentRating = usCertFromContentRatings(details.content_ratings ?? {}); const prodCountries: any[] = details.production_countries ?? []; const originCountries: string[] = details.origin_country ?? []; - country = prodCountries[0]?.name ?? originCountries[0] ?? ''; + if (prodCountries[0]?.name) { + country = prodCountries[0].name; + } else { + const prevCountry = stripQuotes(prev['country']); + if (prevCountry && !isBareIsoCode(prevCountry)) { + country = prevCountry; + } else { + country = originCountries[0] ?? ''; + } + } seasons = details.number_of_seasons ?? null; episodes = details.number_of_episodes ?? null; vod = (details.networks ?? []).map((n: any) => n.name); diff --git a/tests/watchlist-build.test.ts b/tests/watchlist-build.test.ts index 53827f8..8cbe6be 100644 --- a/tests/watchlist-build.test.ts +++ b/tests/watchlist-build.test.ts @@ -146,6 +146,23 @@ describe('TV country derivation', () => { const r = buildRecord(noProdCountries, false, EMPTY_PREV); expect(r.country).toBe('US'); }); + test('TV no production_countries, prev full country name → preserves prev', () => { + const { production_countries, ...noProdCountries } = tvDetail as any; + const prev = { country: 'United States of America' }; + const r = buildRecord(noProdCountries, false, prev); + expect(r.country).toBe('United States of America'); + }); + test('TV no production_countries, prev bare ISO code → uses origin_country fallback', () => { + const { production_countries, ...noProdCountries } = tvDetail as any; + const prev = { country: 'US' }; + const r = buildRecord(noProdCountries, false, prev); + expect(r.country).toBe('US'); + }); + test('TV production_countries present, prev differs → uses production_countries name', () => { + const prev = { country: 'Canada' }; + const r = buildRecord(tvDetail, false, prev); + expect(r.country).toBe('United States of America'); + }); }); describe('tmdb_rating rounding', () => { From fdd8609e49df77c197425e9af8ed0bd810533d4d Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 14:35:07 +0800 Subject: [PATCH 23/53] docs: phase 2 multi-type design spec --- .../2026-08-03-phase2-multitype-design.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-phase2-multitype-design.md diff --git a/docs/superpowers/specs/2026-08-03-phase2-multitype-design.md b/docs/superpowers/specs/2026-08-03-phase2-multitype-design.md new file mode 100644 index 0000000..18ace37 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-phase2-multitype-design.md @@ -0,0 +1,89 @@ +# Phase 2 — Manga / Books / Games / Comics Design + +Approved 2026-08-03. Extends the watchlist suite (Phase 1, shipped) to four more media types using the same canonical-schema + sync-engine pattern. + +## Context + +Vault folders exist (migrated 2026-08-02, stock Media DB skeleton frontmatter, `dataSource: manual`, empty `id`): +- `02 - Areas/Interests/Mangas/` — 25 entries +- `02 - Areas/Interests/Books/` — 14 entries +- `02 - Areas/Interests/Games/` — 31 entries +- `02 - Areas/Interests/Comics/` — 7 entries + +Each folder has `_index.md` (+ some `_Misc.md`) — `_`-prefixed, skipped by engine. Entries carry `## Collection` (`Part of [[Mangas]]` etc.) and `## My Notes` — both survive sync via Phase 1's custom-section preservation. No `.base` views yet for these types. + +## Decisions (user-approved) + +1. **Canonical per-type schemas** (Watchlist pattern), NOT stock Media DB fields. Existing skeleton entries convert on first sync. +2. **Manga chapter tracking**: user-owned `read_status` + `last_read_chapter`; automated `latest_chapter` detection with source priority **RSS (per-note feed URL) → MangaDex → Jikan finish-flip**; Read→Unread flip + Notice on new chapter. +3. **Games**: Steam first (no key), RAWG fallback (free key) for non-Steam titles. +4. **Comics**: Comic Vine (free key) + issue-flip automation (`latest_issue`, `last_read_issue`). +5. **Books**: Open Library, metadata refresh only — no automation. + +## Schemas + +Common to all four (mirrors watchlist conventions): `type: {manga|book|game|comic}_item`; user-owned preserved on every write: `rating`, `rating_stars`, status field (below), progress fields (below), `rss`, body `## My Notes` + `## Collection` + any custom `##` section; `tags: [, ]`; ISO string dates; `poster` (cover URL); `synopsis` in body render like watchlist (`# Title`, meta line, status lines, `## Synopsis`, type-specific crew/detail lines, `## Links`, custom sections, `## My Notes`). + +### manga_item +| Field | Source | Notes | +|---|---|---| +| `mal_id` | resolve/Jikan | sync key | +| `mangadex_id` | resolve/MangaDex | chapter feed key; blank if unmatched | +| `rss` | **user** | optional feed URL, top chapter source | +| `title`, `eng_name` | Jikan | eng_name logic like watchlist | +| `authors` | Jikan | comma-joined | +| `genre` | Jikan | list | +| `status` | Jikan | Publishing / Finished / On Hiatus | +| `chapters`, `volumes` | Jikan | totals; null while Publishing | +| `score` | Jikan | 1dp (analog of tmdb_rating) | +| `published_from`, `published_to` | Jikan | ISO | +| `read_status` | **user** | Reading / Read / Unread / Dropped | +| `last_read_chapter` | **user** | number, manual | +| `latest_chapter` | RSS/MangaDex | number or null | +| `last_chapter_date` | RSS/MangaDex | ISO | +| `poster`, `synopsis`, `url` (MAL page) | Jikan | | + +**Flip rule (manga)**: `read_status == 'Read'` AND new `latest_chapter` > stored `latest_chapter` (or new item date > stored `last_chapter_date` when numberless) → `read_status: Unread` + Notice naming title + chapter. Also finish-flip: status Publishing→Finished while Read → Unread. + +### comic_item +`comicvine_id` (sync key), `title`, `publisher`, `people` (comma-joined writers/artists; no genre field — Comic Vine has none reliable), `issues` (count_of_issues), `latest_issue` (last issue number), `last_issue_date` (ISO), `start_year`, `status` (derived: last issue within 90d → Ongoing, else Concluded — Comic Vine has no status field), `read_status` (**user**), `last_read_issue` (**user**), `poster`, `synopsis`, `url` (site_detail_url). +**Flip rule**: Read + new `latest_issue` > stored → Unread + Notice. + +### book_item +`olid` (sync key; `isbn` kept if present), `title`, `authors`, `year` (first_publish_year), `pages`, `genre` (subjects, capped 8), `read_status` (**user**), `poster` (cover), `synopsis` (description when available), `url` (OL page). No automation. + +### game_item +`steam_appid` and/or `rawg_id` (either is sync key; steam preferred), `title`, `developer`, `publisher`, `platforms` (list), `genre` (list), `release_date` (ISO), `metacritic` (int/null), `play_status` (**user**: Playing / Played / Unplayed / Dropped), `poster`, `synopsis`, `url` (store/RAWG page). No automation. + +## Architecture + +Generalize Phase 1 engine — Watchlist code untouched, no behavior change: +- `src/watchlist/` stays as-is (movie/tv). +- New `packages/obsidian/src/library/` module: `MediaTypeSpec` interface = `{ typeName, folderSettingKey, idFields, resolve(fm, filename, http): id | null, fetch(http, ids): raw, build(raw, prev): record, render(record, myNotes, customSections): string, isActive(fm): boolean, flipRule(prev, record): record }`. One spec file per type (`manga.ts`, `book.ts`, `game.ts`, `comic.ts`) + shared `rss.ts` (RSS/Atom parse + chapter-number regex `(chapter|ch\.?|#)\s*(\d+(\.\d+)?)/i`). +- Generic `LibrarySyncEngine` = Phase 1 `syncFolder` parameterized by spec (reuses `withRateLimitRetry`, diff-on-write, custom-section preservation, throttle; per-spec throttleMs — Jikan 350ms, Comic Vine 350ms, MangaDex 250ms). +- Tier: manga/comic ACTIVE when status Publishing/Ongoing or read_status Reading or rss set; books/games STATIC (full sync only) after first enrich (missing per-type id or empty `status`-analog → ACTIVE for first pass). +- Adapters: reuse upstream MALAPIManga (Jikan), OpenLibraryAPI, SteamAPI, RAWGAPI, ComicVineAPI request patterns where practical — but Phase 2 fetchers live in library specs w/ injected `HttpJsonFn` like watchlist/tmdb.ts (upstream adapters return their models, we need raw JSON + append params; copying the URL/auth conventions is enough). MangaDex + RSS = new fetchers. +- Settings: per-type enable + folder (defaults `Mangas`/`Books`/`Games`/`Comics`; user sets real vault paths like `02 - Areas/Interests/Mangas`) + RAWG/ComicVine key fields (secretStorage, same pattern as TMDB). Sync interval shared w/ watchlist scheduler; catch-up loop iterates all enabled types. +- Commands: per-type `sync now` / `resolve missing ids` + `dry-run full sync`; global `Sync all libraries`. + +## Resolve pass (first run, ~77 entries) +Per-type resolve cmd: title (+`year` hint where present) → search API → unique-exact-match rule (Phase 1 semantics: 1 exact → accept; 0 exact + sole → accept; else log ambiguous). Games: Steam search miss → RAWG search. Manga: Jikan resolve → then MangaDex search by title for `mangadex_id` (best-effort; miss = blank, log). Writes id fields into frontmatter (patchFrontmatter pattern); enrichment happens on next sync (missing status → ACTIVE tier). + +## Bases +Generate 4 `.base` files in `02 - Areas/Interests/`: filter `type == '_item'` + folder; columns per type (manga: title/read_status/last_read_chapter/latest_chapter/status/score; comics: analog w/ issues; books: title/read_status/authors/year/pages; games: title/play_status/platforms/metacritic/release_date). Modeled on existing `Watchlist.base` file format (read it during implementation). + +## Rate limits +Jikan 3 req/s (no key), MangaDex ~5 req/s (no key), Comic Vine 200 req/hr (7 comics — fine), Open Library lenient, Steam lenient, RAWG 20k/mo free. Per-spec throttle + existing 429 retry covers all. + +## Error/edge policy +- Missing key (RAWG/Comic Vine) → per-type sync skips w/ one Notice, other types proceed. +- RSS fetch failure → log, fall back to MangaDex that pass. +- MangaDex title-match ambiguity → blank mangadex_id + log (user can paste id manually). +- Numberless RSS titles → date-based new-item detection, `latest_chapter` stays null. +- Skeleton→canonical conversion: first sync maps stock fields (`read`→read_status Read/Unread, `personalRating`→rating, existing url→kept in body Links) then rewrites canonical; dry-run first against real vault, git snapshot before live (same protocol as Phase 1). + +## Out of scope +Watchlist changes, per-episode TV crew (Phase 3), push notifications outside Obsidian, BGG/IGDB/VNDB adapters, community-plugin submission. + +## Verification protocol (mirrors Phase 1) +Gates: `bun run test` / `bun run typecheck` / `bun run build` every task. Golden fixtures per type from real vault notes. Final: resolve pass → dry-run vs real folders (77 entries) → user reviews counts + sample diffs → git snapshot → live → `git diff` audit (user-field preservation checks scripted as in Phase 1). From 1e7e6d1032fb52baf808d66dcd31edd2efd68a71 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 14:59:05 +0800 Subject: [PATCH 24/53] docs: phase 2 implementation plan --- .../plans/2026-08-03-phase2-multitype.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-phase2-multitype.md diff --git a/docs/superpowers/plans/2026-08-03-phase2-multitype.md b/docs/superpowers/plans/2026-08-03-phase2-multitype.md new file mode 100644 index 0000000..0a852b9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-phase2-multitype.md @@ -0,0 +1,220 @@ +# Phase 2 Multi-Type Library Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Extend the plugin to manga/books/games/comics: canonical schemas, enrichment, resolve, tiered sync w/ chapter/issue flip automation (RSS → MangaDex → Jikan for manga; Comic Vine for comics). + +**Architecture:** New `packages/obsidian/src/library/` module. Per-type `MediaTypeSpec` objects (manga/book/game/comic) plug into one generic `librarySync` engine that copies Phase 1's proven shape (diff-on-write, tier, throttle, 429 retry via `withRateLimitRetry`, custom-section + user-field preservation). Watchlist code untouched. + +**Tech Stack:** bun + vite; tests `bun run test` (NEVER raw `bun test`); typecheck `bun run typecheck`; build `bun run build`. All three green before every commit. + +**Spec:** `docs/superpowers/specs/2026-08-03-phase2-multitype-design.md` — field tables + decisions are authoritative. Phase 1 pattern anchors: `packages/obsidian/src/watchlist/` (parse.ts helpers reusable as-is; SyncEngine.ts = engine shape to copy; WatchlistController.ts = controller idioms; tmdb.ts = fetcher shape). + +## Global Constraints + +- ALL HTTP via injected `HttpJsonFn`/`HttpTextFn`; plugin wires `obsidianFetch` (see `WatchlistController.makeHttp`). Never node fetch. +- Preserve on every write: `rating`, `rating_stars`, per-type status field (`read_status`/`play_status`), `last_read_chapter`/`last_read_issue`, `rss`, all id fields once set, body `## My Notes` + `## Collection` + any custom `##` section (use Phase 1 `extractCustomSections`/`renderNote` pattern). +- ISO string dates, lexicographic compare, no `Date` parsing of data values. +- Diff-on-write; dryRun counts without writing. +- Ratings/scores 1 decimal. +- Per-spec throttleMs: jikan 350, mangadex 250, comicvine 350, openlibrary 250, steam 250, rawg 250, rss 100. +- Unique-exact-match resolve semantics (Phase 1 `resolve.ts` rule: 1 exact → accept; 0 exact + sole result → accept; else null). +- NO Claude/AI attribution in commit messages. +- Real vault data (fixtures source): `/Users/AfiqZudinHadi/Documents/ai_brain/02 - Areas/Interests/{Mangas,Books,Games,Comics}/` — entries currently stock-skeleton fm (`type: comicManga|book|game`, `dataSource: manual`, empty `id`); first sync converts to canonical. + +### API endpoints (verify response shapes against docs/adapters during impl; upstream adapters in `packages/obsidian/src/api/apis/` show auth/URL conventions) + +| API | Resolve | Enrich | Auth | +|---|---|---|---| +| Jikan | `GET https://api.jikan.moe/v4/manga?q={title}&limit=10` | `GET https://api.jikan.moe/v4/manga/{mal_id}/full` | none | +| MangaDex | `GET https://api.mangadex.org/manga?title={t}&limit=10` | `GET https://api.mangadex.org/manga/{id}/feed?order[readableAt]=desc&limit=1&translatedLanguage[]=en` → latest chapter `attributes.chapter` + `attributes.readableAt` | none | +| Open Library | `GET https://openlibrary.org/search.json?q={t}&limit=10` (docs[].key=`/works/OL…W`, author_name, first_publish_year, number_of_pages_median, subject, cover_i) | search doc is enough — no second call needed | none | +| Steam | appid from note `url` regex `store\.steampowered\.com/app/(\d+)` (28/31 games have it); else name search `GET https://store.steampowered.com/api/storesearch/?term={t}&cc=us&l=en` | `GET https://store.steampowered.com/api/appdetails?appids={id}&cc=us&l=en` → `{[id]:{success,data:{name,developers,publishers,genres[].description,release_date.date,metacritic.score,header_image,short_description}}}` (release_date.date = "2 Mar, 2018" → parse to ISO) | none | +| RAWG | `GET https://api.rawg.io/api/games?key={k}&search={t}&page_size=10` | `GET https://api.rawg.io/api/games/{id}?key={k}` → name, developers[].name, publishers[].name, platforms[].platform.name, genres[].name, released (ISO), metacritic, background_image, description_raw | key (query param) | +| Comic Vine | `GET https://comicvine.gamespot.com/api/volumes/?api_key={k}&format=json&filter=name:{t}&limit=10` | `GET https://comicvine.gamespot.com/api/volume/4050-{id}/?api_key={k}&format=json` → name, publisher.name, count_of_issues, last_issue{issue_number,name}, start_year, image.original_url, description (HTML → strip tags), people[].name | key; also send `User-Agent` style headers per ComicVineAPI.ts conventions | +| RSS | note `rss` field URL → `GET` raw XML | items via regex parse (no DOMParser dependency): `` / ``, fields title / pubDate|updated / guid|id | none | + +--- + +### Task 1: Library core — types + RSS/Atom parser + +**Files:** +- Create: `packages/obsidian/src/library/types.ts` +- Create: `packages/obsidian/src/library/rss.ts` +- Test: `tests/library-rss.test.ts` + +**Interfaces (produces):** + +```ts +// types.ts +export type HttpJsonFn = (url: string, headers: Record) => Promise; +export type HttpTextFn = (url: string, headers: Record) => Promise; + +export interface LibraryNoteCtx { + frontmatter: Record; + body: string; + filename: string; +} + +export interface MediaTypeSpec { + typeName: 'manga' | 'book' | 'game' | 'comic'; + itemType: string; // 'manga_item' etc. + folderSettingKey: string; + enabledSettingKey: string; + throttleMs: number; + hasId(fm: Record): boolean; + isActive(fm: Record): boolean; + resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null>; // returns fm patches (id fields) + sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null>; // full new note content; null = skip +} + +export interface SpecDeps { + http: HttpJsonFn; + httpText: HttpTextFn; + getKey(name: 'rawg' | 'comicvine'): string; // '' when unset + log(msg: string): void; + notify(msg: string): void; +} +``` + +```ts +// rss.ts +export interface FeedItem { title: string; date: string; id: string } // date ISO or '' +export function parseFeed(xml: string): FeedItem[]; // RSS 2.0 + Atom , regex-based, entities decoded (& < > " '), CDATA unwrapped +export function extractChapterNumber(title: string): number | null; // /(chapter|ch\.?|#)\s*(\d+(\.\d+)?)/i → parseFloat; null when no match +export function latestChapter(items: FeedItem[]): { chapter: number | null; date: string; title: string } | null; // items assumed newest-first; falls back to date ordering when parseable +``` + +- [ ] **Step 1: failing tests** — `tests/library-rss.test.ts`: RSS 2.0 sample (3 items, titles "Chainsaw Man Chapter 214" / "…Chapter 213.5" / "…Chapter 213", pubDate RFC822 → ISO date conversion via `new Date(x).toISOString().slice(0,10)` is ALLOWED here — parsing feed metadata, not vault data), Atom sample (`Ch. 12 — name2026-08-01T…`), CDATA title, entity-encoded title, numberless titles → `extractChapterNumber` null + `latestChapter` date-based, empty/garbage xml → `[]`. Chapter regex cases: "Chapter 214" 214, "ch.213.5" 213.5, "#77" 77, "Episode 5" null, "Vol. 3 Chapter 21" 21. +- [ ] **Step 2:** `bun run test` → fail (module not found) +- [ ] **Step 3:** implement `rss.ts` + `types.ts` +- [ ] **Step 4:** all gates green +- [ ] **Step 5:** commit `feat(library): core types + rss/atom chapter feed parser` + +--- + +### Task 2: Manga spec (Jikan + MangaDex + RSS, flip rules) + +**Files:** +- Create: `packages/obsidian/src/library/manga.ts` +- Create: `tests/fixtures/jikan-manga-csm.json` (realistic Jikan `/manga/{id}/full` payload for Chainsaw Man: mal_id 116778, title, title_english, authors[].name, genres[].name, status "Publishing", chapters null, volumes null, score 8.73, published.from/to ISO-ish, synopsis, images.jpg.large_image_url, url) +- Create: `tests/fixtures/mangadex-feed.json` (feed response: data[0].attributes {chapter:"214", readableAt:"2026-07-30T…"}) +- Test: `tests/library-manga.test.ts` + +**Canonical manga_item frontmatter order** (analog of watchlist renderNote — golden-test this): +`type: manga_item`, `title`, `eng_name`, `read_status`, `rating`, `rating_stars`, `last_read_chapter`, `latest_chapter`, `last_chapter_date`, `chapters`, `volumes`, `status`, `authors`, `genre`, `score`, `published_from`, `published_to`, `mal_id`, `mangadex_id`, `rss`, `poster`, `url`, `tags: [mangas, manga]`. +Body: `# Title`, `*eng_name*` opt, poster opt, meta line `**Manga** · status · score`, `**Read Status:** …` + `**Progress:** ch. {last_read_chapter} / {latest_chapter ?? chapters ?? '?'}` when last_read_chapter set, `## Synopsis`, `**Authors:** …`, `## Links` (MAL page + RSS feed link when set), custom sections, `## My Notes`. + +**Behavior (test each):** +- buildManga(jikan, prev): field mapping incl score 1dp (8.73→8.7), eng_name logic (title_english ≠ title → eng_name), status passthrough (Publishing/Finished/On Hiatus). +- User fields preserved: read_status (default Unread), rating, rating_stars, last_read_chapter, rss, mangadex_id (kept if set even when resolve missed). +- Chapter source priority in `sync()`: rss field set → fetch feed via httpText → `latestChapter()`; else mangadex_id set → feed endpoint; else latest_chapter unchanged. New latest > stored (or numberless: new date > stored `last_chapter_date`) → update fields + flip `read_status Read → Unread` + `deps.notify('«title» ch. N out')`. Reading/Unread/Dropped never flipped. +- Finish-flip: prev status Publishing + new Finished + read_status Read → Unread. +- `isActive`: status Publishing/On Hiatus OR read_status Reading OR rss set OR mal_id empty/status empty (first pass). Finished + Read/Unread → static. +- Skeleton conversion: prev stock fm (`read: false`, `personalRating:`, empty canonical fields) → canonical defaults (read: 'true'→read_status Read, else Unread; personalRating n→rating n + stars emoji ×n). +- RSS fetch failure → log, proceed w/ mangadex/jikan (no throw). + +- [ ] Steps: TDD (fixtures + tests → fail → implement → gates) → commit `feat(library): manga spec — jikan enrich, rss/mangadex chapter flip` + +--- + +### Task 3: Book spec (Open Library, static) + +**Files:** Create `packages/obsidian/src/library/book.ts`, `tests/fixtures/openlibrary-search.json` (docs[0] for 1984: key "/works/OL1168083W", author_name ["George Orwell"], first_publish_year 1949, number_of_pages_median 328, subject [...12+ entries], cover_i 12919016), test `tests/library-book.test.ts`. + +**book_item fm order:** `type: book_item`, `title`, `read_status`, `rating`, `rating_stars`, `authors`, `year`, `pages`, `genre` (subjects capped 8), `olid`, `isbn` (preserve if prev had), `poster` (`https://covers.openlibrary.org/b/id/{cover_i}-L.jpg`), `url` (`https://openlibrary.org{key}`), `tags: [books, book]`. Body analog (meta line `**Book** · year · pages p.`). No automation; `isActive` = olid empty only (first pass); after that STATIC. +Skeleton conversion: `author`→authors, `read`→read_status, `personalRating`→rating/stars, goodreads `url` → kept as `## Links` entry "Goodreads". + +- [ ] TDD → gates → commit `feat(library): book spec — open library` + +--- + +### Task 4: Game spec (Steam primary, RAWG fallback) + +**Files:** Create `packages/obsidian/src/library/game.ts`, fixtures `steam-appdetails.json` (792100: name "7 Billion Humans", developers, publishers, genres[].description, release_date {date:"2 Mar, 2018"}, metacritic{score:79}, header_image, short_description) + `rawg-game.json`, test `tests/library-game.test.ts`. + +**game_item fm order:** `type: game_item`, `title`, `play_status`, `rating`, `rating_stars`, `developer`, `publisher`, `platforms`, `genre`, `release_date` (ISO — parse Steam "2 Mar, 2018"; month-name map, no locale dependence), `metacritic`, `steam_appid`, `rawg_id`, `poster`, `url`, `tags: [games, game]`. +- resolve: url regex appid → done; else Steam storesearch unique-exact; else RAWG search (needs key; keyless → log skip). +- enrich: steam_appid → appdetails (handle `success:false` → fall to RAWG if rawg_id/key); rawg_id → RAWG detail. Steam gives no platforms list → `platforms: [PC]` default when Steam-only. +- `played: true` skeleton → play_status Played, else Unplayed. `isActive` = ids empty (first pass) only. + +- [ ] TDD → gates → commit `feat(library): game spec — steam + rawg` + +--- + +### Task 5: Comic spec (Comic Vine + issue flip) + +**Files:** Create `packages/obsidian/src/library/comic.ts`, fixture `comicvine-volume.json` (results: name "Absolute Batman", publisher.name "DC Comics", count_of_issues 10, last_issue {issue_number:"10", name}, start_year "2024", image.original_url, description "

", people[].name), test `tests/library-comic.test.ts`. + +**comic_item fm order:** `type: comic_item`, `title`, `read_status`, `rating`, `rating_stars`, `last_read_issue`, `latest_issue`, `issues`, `status` (derived: within-90d rule needs a date — Comic Vine volume has no last-issue date; use `date_last_updated` when present, else keep prev status, default Ongoing), `publisher`, `people`, `start_year`, `comicvine_id`, `poster`, `url` (site_detail_url), `tags: [comics, comic]`. +- description HTML → plain text (strip tags, decode entities, first 2 paragraphs). +- Flip: read_status Read + new latest_issue (numeric compare of issue_number) > stored → Unread + notify. +- Comic Vine auth: `api_key` query param + `format=json`; check ComicVineAPI.ts for any required header quirks. 200 req/hr → throttleMs 350 fine at n=7. +- `isActive`: status Ongoing OR read_status Reading OR comicvine_id empty. + +- [ ] TDD → gates → commit `feat(library): comic spec — comic vine + issue flip` + +--- + +### Task 6: Generic library sync engine + +**Files:** Create `packages/obsidian/src/library/LibraryEngine.ts`, test `tests/library-engine.test.ts`. + +Copy `watchlist/SyncEngine.ts` shape exactly (deps: listNotes paths-only + readNote fresh-read, writeNote, sleep, log — see Phase 1 post-fix contract), parameterized by `MediaTypeSpec` + `SpecDeps`: + +```ts +export interface LibraryEngineDeps { + listNotes(): Promise<{ path: string }[]>; + readNote(path: string): Promise; + writeNote(path: string, content: string): Promise; + sleep(ms: number): Promise; + log(msg: string): void; + specDeps: SpecDeps; +} +export interface LibraryReport { scanned: number; synced: number; written: number; skippedNoId: number; skippedStatic: number; flipped: string[]; errors: { path: string; error: string }[] } +export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngineDeps, opts: { full?: boolean; dryRun?: boolean }): Promise +export async function libraryFolderResolve(spec: MediaTypeSpec, deps: LibraryEngineDeps, opts: { dryRun?: boolean }): Promise<{ resolved: string[]; ambiguous: string[]; errors: { path: string; error: string }[] }> +``` + +Per note: fresh read → parse → `_`-prefix + non-entry skip (`type` present and ≠ spec.itemType and ≠ stock skeleton types `comicManga|book|game` → skip) → `spec.hasId`? no → skippedNoId : tier via `spec.isActive` (unless full) → `withRateLimitRetry(() => spec.sync(ctx, specDeps), sleep)` → diff-on-write → flip bookkeeping → throttle `spec.throttleMs`. Resolve variant mirrors Phase 1 `resolveMissingIds` w/ `spec.resolve` + patchFrontmatter (extract Phase 1's `patchFrontmatter` from WatchlistController into shared util both use — move, keep watchlist tests green). + +Tests: engine w/ fake spec — tier skip, full override, diff-on-write skip, dryRun no-write, flip report, 429 retry (spec.sync throws TmdbRateLimitError once), error isolation, fresh-read mid-sync (Phase 1 test pattern), stock-skeleton note NOT skipped (converts), `type: folder_index` note skipped. + +- [ ] TDD → gates → commit `feat(library): generic per-type sync engine + resolve` + +--- + +### Task 7: Controller + settings + commands wiring + +**Files:** Modify `packages/obsidian/src/settings/Settings.ts`, `packages/obsidian/src/main.ts`; Create `packages/obsidian/src/library/LibraryController.ts`; test `tests/library-controller.test.ts`. + +Settings additions (interface + defaults + UI group "Library sync"): per type `{manga,book,game,comic}Enabled` (false) + `…Folder` (defaults `Mangas`/`Books`/`Games`/`Comics`) ; `RAWGLibKeyId`? — NO: reuse existing `RAWGAPIKeyId` + `ComicVineKeyId` secretStorage entries (stock settings already have them — verify names at Settings.ts L53-70; no new key fields). +`LibraryController` mirrors `WatchlistController`: makeHttp shared (extract/reuse), makeHttpText analog (obsidianFetch → `res.text()`), specDeps.getKey via secretStorage, syncType(spec, full, dryRun, quiet), resolveType(spec, dryRun), syncAll(full) iterating enabled specs, `syncing` guard SHARED with watchlist? — separate flag ok (different folders, no file overlap). +Scheduler: extend `maybeCatchUp` path — after watchlist sync, run `libraryController.syncAll(false, quiet=true)` for enabled types (same lastSync? separate `libraryLastSync` — yes separate, same interval setting). +Commands (each w/ `.catch(Notice)` pattern): per type `Sync {mangas|books|games|comics} now`, `Resolve {…} ids`, `Dry-run {…} full sync`; global `Sync all libraries`. + +Tests: controller-level maybeCatchUp/guard analogs (fake plugin), getKey missing-key behavior (RAWG/CV absent → spec skip Notice, others run). + +- [ ] TDD → gates → commit `feat(library): controller, settings, commands, scheduler` + +--- + +### Task 8: Bases update + real-vault verification (user-gated) + +No plugin code. Direct vault edits + protocol: + +- [ ] Update 4 `.base` files (`Mangas/Mangas.base` etc.): filters → `type == "manga_item"` (etc., keep `file.inFolder`), properties/columns per spec (manga: file.name/read_status/last_read_chapter/latest_chapter/status/score/genre/authors; comics: file.name/read_status/last_read_issue/latest_issue/issues/status/publisher; books: file.name/read_status/authors/year/pages/rating; games: file.name/play_status/platforms/metacritic/release_date/developer). Keep existing views structure (YAML format per current files). +- [ ] User: paste RAWG + Comic Vine keys into settings (Comic Vine free acct; RAWG free key). Reload Obsidian. +- [ ] Resolve passes (dry-run first, then live): mangas (Jikan + MangaDex), games (url-parse covers 28/31), books, comics. Review ambiguous list → manual id paste where needed. +- [ ] Dry-run full sync all 4 types → review counts + console. STOP for user sign-off. +- [ ] `cd ~/Documents/ai_brain && git add -A && git commit -m "pre-phase2-sync snapshot"`. +- [ ] Live sync all types → `git diff` audit: user-field preservation greps (Phase 1 script pattern: `-## My Notes` count 0, `-## Collection` count 0, rating/stars loss 0), sample diffs per type reviewed by user. +- [ ] User sets `rss` field on actively-followed mangas → next sync verifies RSS flip path live. +- [ ] Commit vault + fork; update fork README feature list. + +--- + +## Self-Review Notes +- Spec coverage: schemas (T2-5), engine+preservation (T6), resolve incl. Steam-url shortcut (T4/T6), settings/keys/commands/scheduler (T7), Bases + verification protocol (T8), RSS parser + priority chain (T1/T2). Books static ✓, no-automation games ✓. +- Deliberate deviations from spec doc: none. Open verify-points flagged in-task: Comic Vine header quirks (ComicVineAPI.ts), RAWG/ComicVine settings key names (Settings.ts), Steam appdetails success:false path. +- Type consistency: `MediaTypeSpec`/`SpecDeps` (T1) consumed T2-7; `LibraryEngineDeps`/`LibraryReport` (T6) consumed T7; patchFrontmatter extraction (T6) touches WatchlistController — its tests must stay green. From c398669ae65ce18601509c2e30bbabc5058efd60 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 15:07:06 +0800 Subject: [PATCH 25/53] feat(library): core types + rss/atom chapter feed parser --- packages/obsidian/src/library/rss.ts | 97 ++++++++++++++++++ packages/obsidian/src/library/types.ts | 28 +++++ tests/library-rss.test.ts | 135 +++++++++++++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 packages/obsidian/src/library/rss.ts create mode 100644 packages/obsidian/src/library/types.ts create mode 100644 tests/library-rss.test.ts diff --git a/packages/obsidian/src/library/rss.ts b/packages/obsidian/src/library/rss.ts new file mode 100644 index 0000000..5f5d681 --- /dev/null +++ b/packages/obsidian/src/library/rss.ts @@ -0,0 +1,97 @@ +export interface FeedItem { + title: string; + date: string; // ISO (YYYY-MM-DD) or '' + id: string; +} + +const BLOCK_RE = /<(item|entry)\b[^>]*>([\s\S]*?)<\/\1>/gi; + +function extractTag(block: string, tag: string): string | null { + const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'); + const m = re.exec(block); + return m ? m[1] : null; +} + +function unwrapCdata(raw: string): string { + const m = /^\s*\s*$/.exec(raw); + return m ? m[1] : raw; +} + +function decodeEntities(raw: string): string { + return raw + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +function cleanText(raw: string | null): string { + if (raw === null) return ''; + return decodeEntities(unwrapCdata(raw)).trim(); +} + +function toIsoDate(raw: string | null): string { + if (!raw) return ''; + const d = new Date(cleanText(raw)); + if (isNaN(d.getTime())) return ''; + return d.toISOString().slice(0, 10); +} + +/** + * RSS 2.0 + Atom , regex-based (no DOM libs). Entities decoded + * (& < > " '), CDATA unwrapped. Invalid/missing dates → ''. + */ +export function parseFeed(xml: string): FeedItem[] { + const items: FeedItem[] = []; + if (!xml) return items; + + let match: RegExpExecArray | null; + BLOCK_RE.lastIndex = 0; + while ((match = BLOCK_RE.exec(xml)) !== null) { + const block = match[2]; + const title = cleanText(extractTag(block, 'title')); + const rawDate = extractTag(block, 'pubDate') ?? extractTag(block, 'updated'); + const id = cleanText(extractTag(block, 'guid') ?? extractTag(block, 'id')); + items.push({ title, date: toIsoDate(rawDate), id }); + } + + return items; +} + +const CHAPTER_RE = /(chapter|ch\.?|#)\s*(\d+(\.\d+)?)/i; + +/** /(chapter|ch\.?|#)\s*(\d+(\.\d+)?)/i → parseFloat; null when no match */ +export function extractChapterNumber(title: string): number | null { + const m = CHAPTER_RE.exec(title); + if (!m) return null; + return parseFloat(m[2]); +} + +/** + * Items assumed newest-first. Picks the item with the highest parseable + * chapter number; falls back to date ordering (max ISO date) when no item + * title yields a parseable chapter number. + */ +export function latestChapter(items: FeedItem[]): { chapter: number | null; date: string; title: string } | null { + if (items.length === 0) return null; + + let best: FeedItem | null = null; + let bestChapter: number | null = null; + + for (const item of items) { + const chapter = extractChapterNumber(item.title); + if (chapter !== null && (bestChapter === null || chapter > bestChapter)) { + bestChapter = chapter; + best = item; + } + } + + if (best) return { chapter: bestChapter, date: best.date, title: best.title }; + + let latest = items[0]; + for (const item of items) { + if (item.date && (!latest.date || item.date > latest.date)) latest = item; + } + return { chapter: null, date: latest.date, title: latest.title }; +} diff --git a/packages/obsidian/src/library/types.ts b/packages/obsidian/src/library/types.ts new file mode 100644 index 0000000..e326e2b --- /dev/null +++ b/packages/obsidian/src/library/types.ts @@ -0,0 +1,28 @@ +export type HttpJsonFn = (url: string, headers: Record) => Promise; +export type HttpTextFn = (url: string, headers: Record) => Promise; + +export interface LibraryNoteCtx { + frontmatter: Record; + body: string; + filename: string; +} + +export interface MediaTypeSpec { + typeName: 'manga' | 'book' | 'game' | 'comic'; + itemType: string; // 'manga_item' etc. + folderSettingKey: string; + enabledSettingKey: string; + throttleMs: number; + hasId(fm: Record): boolean; + isActive(fm: Record): boolean; + resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null>; // returns fm patches (id fields) + sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null>; // full new note content; null = skip +} + +export interface SpecDeps { + http: HttpJsonFn; + httpText: HttpTextFn; + getKey(name: 'rawg' | 'comicvine'): string; // '' when unset + log(msg: string): void; + notify(msg: string): void; +} diff --git a/tests/library-rss.test.ts b/tests/library-rss.test.ts new file mode 100644 index 0000000..c9f555f --- /dev/null +++ b/tests/library-rss.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'bun:test'; +import { extractChapterNumber, latestChapter, parseFeed } from 'packages/obsidian/src/library/rss'; + +const rssSample = ` + + +Chainsaw Man Updates + +Chainsaw Man Chapter 214 +Thu, 30 Jul 2026 12:00:00 GMT +https://example.com/csm-214 + + +Chainsaw Man Chapter 213.5 +Thu, 23 Jul 2026 12:00:00 GMT +https://example.com/csm-213-5 + + +Chainsaw Man Chapter 213 +Thu, 16 Jul 2026 12:00:00 GMT +https://example.com/csm-213 + + +`; + +const atomSample = ` + +Some Manga Feed + +Ch. 12 — name +2026-08-01T09:30:00Z +https://example.com/entry/12 + +`; + +const cdataSample = ` +<![CDATA[One Piece Chapter 1120]]> +Mon, 01 Jun 2026 00:00:00 GMT +id-cdata +`; + +const entitySample = ` +Attack & Titan 'Special' Chapter 5 +Mon, 01 Jun 2026 00:00:00 GMT +id-entity +`; + +const numberlessSample = ` + +Chainsaw Man - Extra Announcement +Thu, 30 Jul 2026 12:00:00 GMT +a + + +Chainsaw Man - Fan Art Contest +Thu, 16 Jul 2026 12:00:00 GMT +b + +`; + +describe('parseFeed', () => { + test('RSS 2.0 — 3 items, title/date/id mapped, newest first as-authored', () => { + const items = parseFeed(rssSample); + expect(items).toEqual([ + { title: 'Chainsaw Man Chapter 214', date: '2026-07-30', id: 'https://example.com/csm-214' }, + { title: 'Chainsaw Man Chapter 213.5', date: '2026-07-23', id: 'https://example.com/csm-213-5' }, + { title: 'Chainsaw Man Chapter 213', date: '2026-07-16', id: 'https://example.com/csm-213' }, + ]); + }); + + test('Atom — entry title/updated/id mapped', () => { + const items = parseFeed(atomSample); + expect(items).toEqual([{ title: 'Ch. 12 — name', date: '2026-08-01', id: 'https://example.com/entry/12' }]); + }); + + test('CDATA title unwrapped', () => { + const items = parseFeed(cdataSample); + expect(items[0].title).toBe('One Piece Chapter 1120'); + }); + + test('entity-encoded title decoded', () => { + const items = parseFeed(entitySample); + expect(items[0].title).toBe(`Attack & Titan 'Special' Chapter 5`); + }); + + test('empty xml → []', () => { + expect(parseFeed('')).toEqual([]); + }); + + test('garbage xml → []', () => { + expect(parseFeed('not xml at all, just some random text')).toEqual([]); + }); +}); + +describe('extractChapterNumber', () => { + test('"Chapter 214" → 214', () => { + expect(extractChapterNumber('Chapter 214')).toBe(214); + }); + test('"ch.213.5" → 213.5', () => { + expect(extractChapterNumber('ch.213.5')).toBe(213.5); + }); + test('"#77" → 77', () => { + expect(extractChapterNumber('#77')).toBe(77); + }); + test('"Episode 5" → null', () => { + expect(extractChapterNumber('Episode 5')).toBeNull(); + }); + test('"Vol. 3 Chapter 21" → 21', () => { + expect(extractChapterNumber('Vol. 3 Chapter 21')).toBe(21); + }); + test('numberless title → null', () => { + expect(extractChapterNumber('Chainsaw Man - Extra Announcement')).toBeNull(); + }); +}); + +describe('latestChapter', () => { + test('empty items → null', () => { + expect(latestChapter([])).toBeNull(); + }); + + test('RSS sample → highest chapter (214) wins', () => { + const result = latestChapter(parseFeed(rssSample)); + expect(result).toEqual({ chapter: 214, date: '2026-07-30', title: 'Chainsaw Man Chapter 214' }); + }); + + test('Atom sample → single entry chapter 12', () => { + const result = latestChapter(parseFeed(atomSample)); + expect(result).toEqual({ chapter: 12, date: '2026-08-01', title: 'Ch. 12 — name' }); + }); + + test('numberless titles → date-based fallback, chapter null', () => { + const result = latestChapter(parseFeed(numberlessSample)); + expect(result).toEqual({ chapter: null, date: '2026-07-30', title: 'Chainsaw Man - Extra Announcement' }); + }); +}); From abc3489d27a9ca6e6e36824a7804c5a34bec2180 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 15:28:01 +0800 Subject: [PATCH 26/53] =?UTF-8?q?feat(library):=20manga=20spec=20=E2=80=94?= =?UTF-8?q?=20jikan=20enrich,=20rss/mangadex=20chapter=20flip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/obsidian/src/library/manga.ts | 288 +++++++++++++++ tests/fixtures/canonical-manga.md | 46 +++ tests/fixtures/jikan-manga-csm.json | 40 ++ tests/fixtures/mangadex-feed.json | 20 + tests/library-manga.test.ts | 485 +++++++++++++++++++++++++ 5 files changed, 879 insertions(+) create mode 100644 packages/obsidian/src/library/manga.ts create mode 100644 tests/fixtures/canonical-manga.md create mode 100644 tests/fixtures/jikan-manga-csm.json create mode 100644 tests/fixtures/mangadex-feed.json create mode 100644 tests/library-manga.test.ts 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); + }); +}); From 1c90ae9abc0d5377b4feba7492e53c73d6fd681d Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 15:44:48 +0800 Subject: [PATCH 27/53] =?UTF-8?q?feat(library):=20book=20spec=20=E2=80=94?= =?UTF-8?q?=20open=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/obsidian/src/library/book.ts | 187 ++++++++++++++ tests/fixtures/canonical-book.md | 33 +++ tests/fixtures/openlibrary-search.json | 30 +++ tests/library-book.test.ts | 321 +++++++++++++++++++++++++ 4 files changed, 571 insertions(+) create mode 100644 packages/obsidian/src/library/book.ts create mode 100644 tests/fixtures/canonical-book.md create mode 100644 tests/fixtures/openlibrary-search.json create mode 100644 tests/library-book.test.ts diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts new file mode 100644 index 0000000..77f4c9d --- /dev/null +++ b/packages/obsidian/src/library/book.ts @@ -0,0 +1,187 @@ +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 OPENLIBRARY_BASE = 'https://openlibrary.org'; + +export interface BookRecord { + title: string; + readStatus: string; + rating: string; + ratingStars: string; + authors: string[]; + year: number | null; + pages: number | null; + genre: string[]; + olid: string; + isbn: string; + poster: string | null; + 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; + // skeleton conversion: legacy singular `author` field, used only when Open Library gives none + const legacy = stripQuotes(prev['author']); + return legacy ? [legacy] : []; +} + +/** + * Pure mapper: Open Library `search.json` doc + prev frontmatter -> canonical BookRecord. + * User-managed fields (read_status, rating, rating_stars, isbn) are preserved from prev + * (with legacy-skeleton fallback for read_status/rating/authors); everything else is + * freshly derived from the doc on every call. + */ +export function buildBook(doc: any, prev: Record): BookRecord { + const title: string = doc.title ?? stripQuotes(prev['title']) ?? ''; + const { rating, ratingStars } = deriveRating(prev); + const readStatus = deriveReadStatus(prev); + const authors = deriveAuthors(doc, prev); + const genre: string[] = Array.isArray(doc.subject) ? doc.subject.filter(Boolean).slice(0, 8) : []; + const olid = String(doc.key ?? '').replace(/^\/works\//, '') || stripQuotes(prev['olid']); + + return { + title, + readStatus, + rating, + ratingStars, + authors, + year: typeof doc.first_publish_year === 'number' ? doc.first_publish_year : null, + pages: typeof doc.number_of_pages_median === 'number' ? doc.number_of_pages_median : null, + genre, + olid, + isbn: stripQuotes(prev['isbn']), + poster: doc.cover_i ? `https://covers.openlibrary.org/b/id/${doc.cover_i}-L.jpg` : null, + url: doc.key ? `${OPENLIBRARY_BASE}${doc.key}` : olid ? `${OPENLIBRARY_BASE}/works/${olid}` : '', + }; +} + +/** Carries the legacy goodreads search link forward as a `## Links` entry across every re-render. */ +function resolveGoodreadsLink(prev: Record, body: string): string { + const fromBody = /\[Goodreads\]\(([^)]+)\)/.exec(body ?? ''); + if (fromBody) return fromBody[1]; + // first-pass skeleton conversion: legacy plain `url` field IS the goodreads link, before + // it gets overwritten by the canonical Open Library url + const legacyUrl = stripQuotes(prev['url']); + if (legacyUrl && !legacyUrl.includes('openlibrary.org')) return legacyUrl; + return ''; +} + +export function renderBook(r: BookRecord, myNotes: string, customSections: CustomSection[] = [], goodreadsUrl = ''): string { + const fm = [ + '---', + 'type: book_item', + `title: ${yamlScalar(r.title)}`, + `read_status: ${r.readStatus}`, + `rating: ${r.rating}`, + `rating_stars: ${r.ratingStars}`, + `authors: ${yamlList(r.authors)}`, + `year: ${r.year ?? 'null'}`, + `pages: ${r.pages ?? 'null'}`, + `genre: ${yamlList(r.genre)}`, + `olid: ${r.olid}`, + `isbn: ${r.isbn}`, + `poster: ${quotedOrNull(r.poster)}`, + `url: ${quotedOrNull(r.url)}`, + 'tags: [books, book]', + '---', + ]; + + const b: string[] = ['', `# ${r.title}`, '']; + 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}`, ''); + if (r.authors.length) b.push(`**Authors:** ${r.authors.join(', ')}`, ''); + const links: string[] = []; + if (r.url) links.push(`- [Open Library](${r.url})`); + if (goodreadsUrl) links.push(`- [Goodreads](${goodreadsUrl})`); + 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 fetchDocs(query: string, deps: SpecDeps): Promise { + const qs = new URLSearchParams({ q: query, limit: '10' }); + const res = await deps.http(`${OPENLIBRARY_BASE}/search.json?${qs.toString()}`, {}); + return res?.docs ?? []; +} + +export const bookSpec: MediaTypeSpec = { + typeName: 'book', + itemType: 'book_item', + folderSettingKey: 'libraryBookFolder', + enabledSettingKey: 'libraryBookEnabled', + throttleMs: 250, + + hasId(fm: Record): boolean { + return !!stripQuotes(fm['olid']); + }, + + isActive(fm: Record): boolean { + // static once enriched; no chapter/issue sources, no flips -- only a missing + // olid ever makes a book note active again (a full sync bypasses this check) + return !stripQuotes(fm['olid']); + }, + + async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { + const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); + if (!query) return null; + try { + const docs = await fetchDocs(query, deps); + if (docs.length === 0) return null; + const q = query.toLowerCase(); + const exacts = docs.filter(d => String(d.title ?? '').toLowerCase() === q); + const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && docs.length === 1 ? docs[0] : null; + if (!pick) return null; + const olid = String(pick.key ?? '').replace(/^\/works\//, ''); + return olid ? { olid } : null; + } catch (e) { + deps.log(`book 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 olid = stripQuotes(fm['olid']); + if (!olid) return null; // needs resolve() first + + const query = stripQuotes(fm['title']) || ctx.filename.replace(/\.md$/, ''); + let docs: any[]; + try { + docs = await fetchDocs(query, deps); + } catch (e) { + deps.log(`book open library fetch failed (olid ${olid}): ${String(e)}`); + return null; + } + const doc = docs.find(d => String(d.key ?? '') === `/works/${olid}`) ?? docs[0]; + if (!doc) return null; + + const record = buildBook(doc, fm); + const goodreadsUrl = resolveGoodreadsLink(fm, ctx.body); + const content = renderBook(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body), goodreadsUrl); + return { content, flipped: false }; // static spec -- no automation, never flips + }, +}; diff --git a/tests/fixtures/canonical-book.md b/tests/fixtures/canonical-book.md new file mode 100644 index 0000000..f2b1f5f --- /dev/null +++ b/tests/fixtures/canonical-book.md @@ -0,0 +1,33 @@ +--- +type: book_item +title: 1984 +read_status: Read +rating: 5 +rating_stars: ⭐️⭐️⭐️⭐️⭐️ +authors: [George Orwell] +year: 1949 +pages: 328 +genre: [Dystopian fiction, Science fiction, Politics, Totalitarianism, Fiction, Classic literature, "Government, resistance to", Surveillance] +olid: OL1168083W +isbn: 9780451524935 +poster: "https://covers.openlibrary.org/b/id/12919016-L.jpg" +url: "https://openlibrary.org/works/OL1168083W" +tags: [books, book] +--- + +# 1984 + +![poster|200](https://covers.openlibrary.org/b/id/12919016-L.jpg) + +**Book** · 1949 · 328 p. + +**Read Status:** Read + +**Authors:** George Orwell + +## Links +- [Open Library](https://openlibrary.org/works/OL1168083W) +- [Goodreads](https://www.goodreads.com/search?q=1984) + +## My Notes + diff --git a/tests/fixtures/openlibrary-search.json b/tests/fixtures/openlibrary-search.json new file mode 100644 index 0000000..9a0ad5a --- /dev/null +++ b/tests/fixtures/openlibrary-search.json @@ -0,0 +1,30 @@ +{ + "numFound": 1, + "start": 0, + "docs": [ + { + "key": "/works/OL1168083W", + "title": "1984", + "author_name": ["George Orwell"], + "first_publish_year": 1949, + "number_of_pages_median": 328, + "subject": [ + "Dystopian fiction", + "Science fiction", + "Politics", + "Totalitarianism", + "Fiction", + "Classic literature", + "Government, resistance to", + "Surveillance", + "Censorship", + "Propaganda", + "Big Brother (Fictitious character)", + "Thought control", + "Newspeak (Fictitious language)", + "Ministry of Truth (Fictitious organization)" + ], + "cover_i": 12919016 + } + ] +} diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts new file mode 100644 index 0000000..7c63cb3 --- /dev/null +++ b/tests/library-book.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildBook, renderBook, bookSpec, type BookRecord } from 'packages/obsidian/src/library/book'; +import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; +import olFixture from 'tests/fixtures/openlibrary-search.json'; + +const OL_DOC = olFixture.docs[0]; + +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, + }; +} + +describe('buildBook field mapping', () => { + const r = buildBook(OL_DOC, EMPTY_PREV); + test('core fields', () => { + expect(r.title).toBe('1984'); + expect(r.olid).toBe('OL1168083W'); + expect(r.authors).toEqual(['George Orwell']); + expect(r.year).toBe(1949); + expect(r.pages).toBe(328); + expect(r.poster).toBe('https://covers.openlibrary.org/b/id/12919016-L.jpg'); + expect(r.url).toBe('https://openlibrary.org/works/OL1168083W'); + }); + test('genre capped at 8 (fixture has 14 subjects)', () => { + expect(r.genre.length).toBe(8); + expect(r.genre).toEqual([ + 'Dystopian fiction', + 'Science fiction', + 'Politics', + 'Totalitarianism', + 'Fiction', + 'Classic literature', + 'Government, resistance to', + 'Surveillance', + ]); + }); + test('no cover_i -> poster null', () => { + expect(buildBook({ ...OL_DOC, cover_i: undefined }, EMPTY_PREV).poster).toBeNull(); + }); + test('no first_publish_year/number_of_pages_median -> year/pages null', () => { + const r2 = buildBook({ ...OL_DOC, first_publish_year: undefined, number_of_pages_median: undefined }, EMPTY_PREV); + expect(r2.year).toBeNull(); + expect(r2.pages).toBeNull(); + }); +}); + +describe('buildBook user-field preservation', () => { + test('read_status defaults to Unread', () => { + expect(buildBook(OL_DOC, EMPTY_PREV).readStatus).toBe('Unread'); + }); + test('read_status carried from prev', () => { + expect(buildBook(OL_DOC, { read_status: 'Read' }).readStatus).toBe('Read'); + }); + test('rating/rating_stars carried from prev', () => { + const prev = { rating: '5', rating_stars: '⭐️⭐️⭐️⭐️⭐️' }; + const r = buildBook(OL_DOC, prev); + expect(r.rating).toBe('5'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️⭐️'); + }); + test('isbn preserved when prev had it', () => { + expect(buildBook(OL_DOC, { isbn: '9780451524935' }).isbn).toBe('9780451524935'); + }); + test('isbn empty when prev had none', () => { + expect(buildBook(OL_DOC, EMPTY_PREV).isbn).toBe(''); + }); + test('authors always fresh from doc.author_name, ignoring any prior authors', () => { + expect(buildBook(OL_DOC, { authors: '[Someone Else]' }).authors).toEqual(['George Orwell']); + }); +}); + +describe('buildBook skeleton conversion', () => { + test('read: true -> read_status Read', () => { + expect(buildBook(OL_DOC, { read: 'true', personalRating: '' }).readStatus).toBe('Read'); + }); + test('read: false -> read_status Unread', () => { + expect(buildBook(OL_DOC, { read: 'false', personalRating: '' }).readStatus).toBe('Unread'); + }); + test('personalRating 3 -> rating 3 + 3 stars', () => { + const r = buildBook(OL_DOC, { read: 'false', personalRating: '3' }); + expect(r.rating).toBe('3'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️'); + }); + test('empty personalRating -> rating 0, no stars', () => { + const r = buildBook(OL_DOC, { read: 'false', personalRating: '' }); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + }); + test('legacy `author` used only when doc has no author_name', () => { + const r = buildBook({ ...OL_DOC, author_name: undefined }, { author: 'George Orwell' }); + expect(r.authors).toEqual(['George Orwell']); + }); + test('no doc author_name and no legacy author -> empty authors', () => { + const r = buildBook({ ...OL_DOC, author_name: undefined }, EMPTY_PREV); + expect(r.authors).toEqual([]); + }); +}); + +describe('renderBook golden', () => { + const RECORD: BookRecord = { + title: '1984', + readStatus: 'Read', + rating: '5', + ratingStars: '⭐️⭐️⭐️⭐️⭐️', + authors: ['George Orwell'], + year: 1949, + pages: 328, + genre: ['Dystopian fiction', 'Science fiction', 'Politics', 'Totalitarianism', 'Fiction', 'Classic literature', 'Government, resistance to', 'Surveillance'], + olid: 'OL1168083W', + isbn: '9780451524935', + poster: 'https://covers.openlibrary.org/b/id/12919016-L.jpg', + url: 'https://openlibrary.org/works/OL1168083W', + }; + + test('matches canonical book fixture byte-for-byte', () => { + const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-book.md'), 'utf-8'); + expect(renderBook(RECORD, '', [], 'https://www.goodreads.com/search?q=1984')).toBe(expected); + }); + + test('custom section (Collection) placed after Links, before My Notes', () => { + const out = renderBook(RECORD, '', [{ heading: 'Collection', content: 'Part of [[Books]]' }], 'https://www.goodreads.com/search?q=1984'); + 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 [[Books]]\n'); + }); + + test('My Notes content preserved', () => { + const out = renderBook(RECORD, 'reread every few years'); + expect(out).toContain('## My Notes\n\nreread every few years'); + }); + + test('no goodreads url -> Links has only Open Library entry', () => { + const out = renderBook(RECORD, ''); + expect(out).toContain('- [Open Library](https://openlibrary.org/works/OL1168083W)'); + expect(out).not.toContain('[Goodreads]'); + }); + + test('no poster -> poster line omitted', () => { + const r = { ...RECORD, poster: null }; + expect(renderBook(r, '')).not.toContain('![poster'); + }); + + test('no authors -> Authors line omitted', () => { + const r = { ...RECORD, authors: [] }; + expect(renderBook(r, '')).not.toContain('**Authors:**'); + }); +}); + +describe('bookSpec.hasId', () => { + test('olid set -> true', () => expect(bookSpec.hasId({ olid: 'OL1168083W' })).toBe(true)); + test('olid empty -> false', () => expect(bookSpec.hasId({ olid: '' })).toBe(false)); + test('olid missing -> false', () => expect(bookSpec.hasId({})).toBe(false)); +}); + +describe('bookSpec.isActive', () => { + test('olid empty -> active (needs first pass)', () => { + expect(bookSpec.isActive({})).toBe(true); + }); + test('olid set -> static, regardless of read_status', () => { + expect(bookSpec.isActive({ olid: 'OL1168083W', read_status: 'Reading' })).toBe(false); + }); + test('olid set + Read -> static', () => { + expect(bookSpec.isActive({ olid: 'OL1168083W', read_status: 'Read' })).toBe(false); + }); + test('olid set + Unread -> static', () => { + expect(bookSpec.isActive({ olid: 'OL1168083W', read_status: 'Unread' })).toBe(false); + }); +}); + +function ctxFor(fm: Record, body = '## My Notes\n\n'): LibraryNoteCtx { + return { frontmatter: fm, body, filename: '1984.md' }; +} + +describe('bookSpec.sync — open library enrich', () => { + test('no olid -> null (needs resolve first)', async () => { + const deps = makeDeps(); + const result = await bookSpec.sync(ctxFor({}), deps); + expect(result).toBeNull(); + }); + + test('open library fetch failure -> log, return null (no throw)', async () => { + const deps = makeDeps({ + http: async () => { + throw new Error('network down'); + }, + }); + const result = await bookSpec.sync(ctxFor({ olid: 'OL1168083W', title: '1984' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); + + test('doc not found for olid, no results at all -> null', async () => { + const deps = makeDeps({ http: async () => ({ docs: [] }) }); + const result = await bookSpec.sync(ctxFor({ olid: 'OL1168083W', title: '1984' }), deps); + expect(result).toBeNull(); + }); + + test('successful enrich -> canonical fm + flipped always false', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984', read_status: 'Read', rating: '5', rating_stars: '⭐️⭐️⭐️⭐️⭐️' }; + const result = await bookSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('type: book_item'); + expect(result!.content).toContain('olid: OL1168083W'); + expect(result!.content).toContain('authors: [George Orwell]'); + expect(result!.content).toContain('year: 1949'); + expect(result!.content).toContain('pages: 328'); + }); + + test('isbn preserved through sync when prev had it', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984', isbn: '9780451524935' }; + const result = await bookSpec.sync(ctxFor(fm), deps); + expect(result!.content).toContain('isbn: 9780451524935'); + }); + + test('first-pass skeleton conversion: legacy author/read/personalRating/goodreads url -> canonical + Links entry, Collection section preserved', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { + olid: 'OL1168083W', + title: '1984', + author: 'George Orwell', + read: 'false', + personalRating: '', + url: 'https://www.goodreads.com/search?q=1984', + }; + const body = '## Collection\n\nPart of [[Books]]\n\n## My Notes\n\n'; + const result = await bookSpec.sync(ctxFor(fm, body), deps); + expect(result!.content).toContain('read_status: Unread'); + expect(result!.content).toContain('authors: [George Orwell]'); + expect(result!.content).toContain('- [Open Library](https://openlibrary.org/works/OL1168083W)'); + expect(result!.content).toContain('- [Goodreads](https://www.goodreads.com/search?q=1984)'); + expect(result!.content).toContain('## Collection\n\nPart of [[Books]]\n'); + }); + + test('goodreads link carried forward from already-converted body, ignoring canonical fm url', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984', url: 'https://openlibrary.org/works/OL1168083W' }; + const body = '## Links\n- [Open Library](https://openlibrary.org/works/OL1168083W)\n- [Goodreads](https://www.goodreads.com/search?q=1984)\n\n## My Notes\n\n'; + const result = await bookSpec.sync(ctxFor(fm, body), deps); + expect(result!.content).toContain('- [Goodreads](https://www.goodreads.com/search?q=1984)'); + }); + + test('no goodreads link anywhere -> Links has only Open Library entry', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984' }; + const result = await bookSpec.sync(ctxFor(fm), deps); + expect(result!.content).toContain('- [Open Library](https://openlibrary.org/works/OL1168083W)'); + expect(result!.content).not.toContain('[Goodreads]'); + }); + + test('My Notes content preserved through sync', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984' }; + const result = await bookSpec.sync(ctxFor(fm, '## My Notes\n\nreread every few years'), deps); + expect(result!.content).toContain('## My Notes\n\nreread every few years'); + }); +}); + +describe('bookSpec.resolve', () => { + test('unique exact title match -> accepted', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toEqual({ olid: 'OL1168083W' }); + }); + test('no exact match, sole result -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ docs: [{ key: '/works/OL999W', title: 'Some Other Title' }] }), + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toEqual({ olid: 'OL999W' }); + }); + test('ambiguous (multiple results, no exact match) -> null', async () => { + const deps = makeDeps({ + http: async () => ({ + docs: [ + { key: '/works/OL1W', title: 'Foo' }, + { key: '/works/OL2W', title: 'Bar' }, + ], + }), + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toBeNull(); + }); + test('no results -> null', async () => { + const deps = makeDeps({ http: async () => ({ docs: [] }) }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), 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 bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); +}); From 0c8f897e46b28d88d546e83525e0c578afe82501 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 15:51:15 +0800 Subject: [PATCH 28/53] fix(library): skip book sync on olid search miss (identity-swap guard) --- packages/obsidian/src/library/book.ts | 13 ++++++++++--- tests/library-book.test.ts | 7 +++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 77f4c9d..93cb79d 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -77,7 +77,8 @@ export function buildBook(doc: any, prev: Record): BookRecord { /** Carries the legacy goodreads search link forward as a `## Links` entry across every re-render. */ function resolveGoodreadsLink(prev: Record, body: string): string { - const fromBody = /\[Goodreads\]\(([^)]+)\)/.exec(body ?? ''); + const linksMatch = /##\s*Links\s*\n([\s\S]*?)(?=\n##\s|$)/.exec(body ?? ''); + const fromBody = linksMatch ? /\[Goodreads\]\(([^)]+)\)/.exec(linksMatch[1]) : null; if (fromBody) return fromBody[1]; // first-pass skeleton conversion: legacy plain `url` field IS the goodreads link, before // it gets overwritten by the canonical Open Library url @@ -176,8 +177,14 @@ export const bookSpec: MediaTypeSpec = { deps.log(`book open library fetch failed (olid ${olid}): ${String(e)}`); return null; } - const doc = docs.find(d => String(d.key ?? '') === `/works/${olid}`) ?? docs[0]; - if (!doc) return null; + if (docs.length === 0) return null; + const doc = docs.find(d => String(d.key ?? '') === `/works/${olid}`); + if (!doc) { + // stored olid no longer in the search results -- never silently swap to a + // different work's data; leave the note untouched (id fields preserved) + deps.log(`book open library search returned no doc matching olid ${olid} (title "${query}")`); + return null; + } const record = buildBook(doc, fm); const goodreadsUrl = resolveGoodreadsLink(fm, ctx.body); diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index 7c63cb3..becdc04 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -215,6 +215,13 @@ describe('bookSpec.sync — open library enrich', () => { expect(result).toBeNull(); }); + test('olid search miss (results present, none match stored olid) -> null, no identity swap, logged', async () => { + const deps = makeDeps({ http: async () => ({ docs: [{ key: '/works/OL999999W', title: '1984', author_name: ['Someone Else'] }] }) }); + const result = await bookSpec.sync(ctxFor({ olid: 'OL1168083W', title: '1984' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('OL1168083W'))).toBe(true); + }); + test('successful enrich -> canonical fm + flipped always false', async () => { const deps = makeDeps({ http: async () => olFixture }); const fm = { olid: 'OL1168083W', title: '1984', read_status: 'Read', rating: '5', rating_stars: '⭐️⭐️⭐️⭐️⭐️' }; From cdc653e6e460eac6f200a01d7a975c50d911a728 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 16:02:56 +0800 Subject: [PATCH 29/53] =?UTF-8?q?feat(library):=20game=20spec=20=E2=80=94?= =?UTF-8?q?=20steam=20+=20rawg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/obsidian/src/library/game.ts | 286 ++++++++++++++++ tests/fixtures/canonical-game.md | 36 ++ tests/fixtures/rawg-game.json | 17 + tests/fixtures/steam-appdetails.json | 21 ++ tests/library-game.test.ts | 470 ++++++++++++++++++++++++++ 5 files changed, 830 insertions(+) create mode 100644 packages/obsidian/src/library/game.ts create mode 100644 tests/fixtures/canonical-game.md create mode 100644 tests/fixtures/rawg-game.json create mode 100644 tests/fixtures/steam-appdetails.json create mode 100644 tests/library-game.test.ts diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts new file mode 100644 index 0000000..7f6fd5a --- /dev/null +++ b/packages/obsidian/src/library/game.ts @@ -0,0 +1,286 @@ +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 STEAM_STORE_BASE = 'https://store.steampowered.com'; +const RAWG_BASE = 'https://api.rawg.io/api'; + +const STEAM_MONTHS: Record = { + jan: '01', + feb: '02', + mar: '03', + apr: '04', + may: '05', + jun: '06', + jul: '07', + aug: '08', + sep: '09', + oct: '10', + nov: '11', + dec: '12', +}; + +/** Steam release_date.date ("2 Mar, 2018") -> ISO "YYYY-MM-DD" via month-name lookup, no Date()/locale. */ +export function parseSteamDate(raw: string): string { + const m = /^(\d{1,2})\s+([A-Za-z]{3,})\s*,\s*(\d{4})$/.exec((raw ?? '').trim()); + if (!m) return ''; + const month = STEAM_MONTHS[m[2].slice(0, 3).toLowerCase()]; + if (!month) return ''; + return `${m[3]}-${month}-${m[1].padStart(2, '0')}`; +} + +export interface GameRecord { + title: string; + playStatus: string; + rating: string; + ratingStars: string; + developer: string[]; + publisher: string[]; + platforms: string[]; + genre: string[]; + releaseDate: string; // ISO or '' + metacritic: number | null; + steamAppid: string; + rawgId: string; + poster: string | null; + url: string; + 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; + // skeleton conversion: legacy boolean `played` field + return stripQuotes(prev['played']) === 'true' ? 'Played' : 'Unplayed'; +} + +/** + * Pure mapper: enrich-source payload (Steam appdetails `data` object, or RAWG game-detail + * object) + prev frontmatter -> canonical GameRecord. User-managed fields (play_status, + * rating, rating_stars) are preserved from prev (with legacy-skeleton fallback); id fields + * are preserved once set on either side, never dropped by the other source's enrich pass. + * Steam never reports a usable platform list here, so Steam-sourced records default to ['PC']. + */ +export function buildGame(source: 'steam' | 'rawg', data: any, prev: Record): GameRecord { + const { rating, ratingStars } = deriveRating(prev); + const playStatus = derivePlayStatus(prev); + const prevSteamAppid = stripQuotes(prev['steam_appid']); + const prevRawgId = stripQuotes(prev['rawg_id']); + + if (source === 'steam') { + const steamAppid = data.steam_appid != null ? String(data.steam_appid) : prevSteamAppid; + return { + title: data.name ?? '', + playStatus, + rating, + ratingStars, + developer: Array.isArray(data.developers) ? data.developers.filter(Boolean) : [], + publisher: Array.isArray(data.publishers) ? data.publishers.filter(Boolean) : [], + platforms: ['PC'], + genre: Array.isArray(data.genres) ? data.genres.map((g: any) => g.description).filter(Boolean) : [], + releaseDate: parseSteamDate(data.release_date?.date ?? ''), + metacritic: typeof data.metacritic?.score === 'number' ? data.metacritic.score : null, + steamAppid, + rawgId: prevRawgId, // enrich-by-steam never touches an existing rawg_id + poster: data.header_image ?? null, + url: `${STEAM_STORE_BASE}/app/${steamAppid}`, + description: data.short_description ?? '', + }; + } + + // rawg + const rawgId = data.id != null ? String(data.id) : prevRawgId; + const prevUrl = stripQuotes(prev['url']); + return { + title: data.name ?? '', + playStatus, + rating, + ratingStars, + developer: Array.isArray(data.developers) ? data.developers.map((d: any) => d.name).filter(Boolean) : [], + publisher: Array.isArray(data.publishers) ? data.publishers.map((p: any) => p.name).filter(Boolean) : [], + platforms: Array.isArray(data.platforms) ? data.platforms.map((p: any) => p.platform?.name).filter(Boolean) : [], + genre: Array.isArray(data.genres) ? data.genres.map((g: any) => g.name).filter(Boolean) : [], + releaseDate: typeof data.released === 'string' ? data.released.slice(0, 10) : '', + metacritic: typeof data.metacritic === 'number' ? data.metacritic : null, + steamAppid: prevSteamAppid, // enrich-by-rawg never touches an existing steam_appid + rawgId, + poster: data.background_image ?? null, + // RAWG's documented detail payload carries no site url/slug we can trust; keep whatever + // the note already had (usually the Steam store page from the vault skeleton), else fall + // back to an id-based RAWG link rather than inventing anything. + url: prevUrl || (rawgId ? `https://rawg.io/games/${rawgId}` : ''), + description: data.description_raw ?? '', + }; +} + +export function renderGame(r: GameRecord, myNotes: string, customSections: CustomSection[] = []): string { + const fm = [ + '---', + 'type: game_item', + `title: ${yamlScalar(r.title)}`, + `play_status: ${r.playStatus}`, + `rating: ${r.rating}`, + `rating_stars: ${r.ratingStars}`, + `developer: ${yamlList(r.developer)}`, + `publisher: ${yamlList(r.publisher)}`, + `platforms: ${yamlList(r.platforms)}`, + `genre: ${yamlList(r.genre)}`, + `release_date: ${r.releaseDate ? r.releaseDate : 'null'}`, + `metacritic: ${r.metacritic ?? 'null'}`, + `steam_appid: ${r.steamAppid}`, + `rawg_id: ${r.rawgId}`, + `poster: ${quotedOrNull(r.poster)}`, + `url: ${quotedOrNull(r.url)}`, + 'tags: [games, game]', + '---', + ]; + + const b: string[] = ['', `# ${r.title}`, '']; + 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}`, ''); + 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, ''); + 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'); +} + +function pickUniqueExact(items: T[], query: string, titleOf: (t: T) => string): T | null { + if (!items || items.length === 0) return null; + const q = query.toLowerCase(); + const exacts = items.filter(it => titleOf(it).toLowerCase() === q); + if (exacts.length === 1) return exacts[0]; + if (exacts.length === 0 && items.length === 1) return items[0]; + return null; +} + +async function fetchSteamSearch(query: string, deps: SpecDeps): Promise { + const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' }); + const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {}); + return Array.isArray(res?.items) ? res.items : []; +} + +async function fetchRawgSearch(query: string, key: string, deps: SpecDeps): Promise { + const qs = new URLSearchParams({ key, search: query, page_size: '10' }); + const res = await deps.http(`${RAWG_BASE}/games?${qs.toString()}`, {}); + return Array.isArray(res?.results) ? res.results : []; +} + +export const gameSpec: MediaTypeSpec = { + typeName: 'game', + itemType: 'game_item', + folderSettingKey: 'libraryGameFolder', + enabledSettingKey: 'libraryGameEnabled', + throttleMs: 250, + + hasId(fm: Record): boolean { + return !!stripQuotes(fm['steam_appid']) || !!stripQuotes(fm['rawg_id']); + }, + + isActive(fm: Record): boolean { + // static once either id is set; no chapter/issue-style automation exists for games -- + // only missing both ids ever makes a game note active again (full sync bypasses this) + return !stripQuotes(fm['steam_appid']) && !stripQuotes(fm['rawg_id']); + }, + + async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { + // fastest, no-network path: 28/31 real game notes already carry a Steam store url + const url = stripQuotes(ctx.frontmatter['url']); + const urlMatch = /store\.steampowered\.com\/app\/(\d+)/.exec(url); + if (urlMatch) return { steam_appid: urlMatch[1] }; + + const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); + if (!query) return null; + + try { + const items = await fetchSteamSearch(query, deps); + const pick = pickUniqueExact(items, query, (it: any) => String(it.name ?? '')); + if (pick) return { steam_appid: String(pick.id) }; + } catch (e) { + deps.log(`game steam storesearch failed for "${query}": ${String(e)}`); + } + + const key = deps.getKey('rawg'); + if (!key) { + deps.log(`game resolve: no RAWG key configured, skipping RAWG fallback for "${query}"`); + return null; + } + try { + const results = await fetchRawgSearch(query, key, deps); + const pick = pickUniqueExact(results, query, (it: any) => String(it.name ?? '')); + if (pick) return { rawg_id: String(pick.id) }; + } catch (e) { + deps.log(`game rawg search failed for "${query}": ${String(e)}`); + } + return null; + }, + + async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> { + const fm = ctx.frontmatter; + const steamAppid = stripQuotes(fm['steam_appid']); + const rawgId = stripQuotes(fm['rawg_id']); + if (!steamAppid && !rawgId) return null; // needs resolve() first + + if (steamAppid) { + let json: any; + try { + json = await deps.http(`${STEAM_STORE_BASE}/api/appdetails?appids=${steamAppid}&cc=us&l=en`, {}); + } catch (e) { + deps.log(`game steam appdetails fetch failed (appid ${steamAppid}): ${String(e)}`); + return null; + } + const entry = json?.[steamAppid]; + if (entry?.success && entry.data) { + const record = buildGame('steam', entry.data, fm); + const content = renderGame(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); + return { content, flipped: false }; + } + // Steam de-listed/invalid appid: identity-guard -- never silently swap to a + // different game; only fall through to RAWG when we have an id + key, else stop. + deps.log(`game steam appdetails returned success:false for appid ${steamAppid}`); + if (!rawgId) return null; + } + + if (rawgId) { + const key = deps.getKey('rawg'); + if (!key) { + deps.log(`game rawg enrich skipped (no key configured) for rawg_id ${rawgId}`); + return null; + } + let data: any; + try { + data = await deps.http(`${RAWG_BASE}/games/${rawgId}?key=${key}`, {}); + } catch (e) { + deps.log(`game rawg detail fetch failed (rawg_id ${rawgId}): ${String(e)}`); + return null; + } + if (!data || !data.name || (data.id != null && String(data.id) !== rawgId)) { + deps.log(`game rawg detail returned no usable data for rawg_id ${rawgId}`); + return null; + } + const record = buildGame('rawg', data, fm); + const content = renderGame(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); + return { content, flipped: false }; + } + + return null; // steam_appid set, success:false, no rawg fallback available + }, +}; diff --git a/tests/fixtures/canonical-game.md b/tests/fixtures/canonical-game.md new file mode 100644 index 0000000..14470f3 --- /dev/null +++ b/tests/fixtures/canonical-game.md @@ -0,0 +1,36 @@ +--- +type: game_item +title: 7 Billion Humans +play_status: Played +rating: 4 +rating_stars: ⭐️⭐️⭐️⭐️ +developer: [Tomorrow Corporation] +publisher: [Tomorrow Corporation] +platforms: [PC] +genre: [Casual, Indie, Strategy] +release_date: 2018-03-02 +metacritic: 79 +steam_appid: 792100 +rawg_id: +poster: "https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg" +url: "https://store.steampowered.com/app/792100" +tags: [games, game] +--- + +# 7 Billion Humans + +![poster|200](https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg) + +**Game** · 2018-03-02 · Metacritic 79 + +**Play Status:** Played + +## Synopsis +From the creators of Human Resource Machine! Program a workforce of dumb humans to do your bidding in this fiendish puzzle game. + +**Developer:** Tomorrow Corporation +**Publisher:** Tomorrow Corporation +**Platforms:** PC + +## My Notes + diff --git a/tests/fixtures/rawg-game.json b/tests/fixtures/rawg-game.json new file mode 100644 index 0000000..9704abb --- /dev/null +++ b/tests/fixtures/rawg-game.json @@ -0,0 +1,17 @@ +{ + "id": 4200, + "name": "Hollow Knight", + "developers": [{ "name": "Team Cherry" }], + "publishers": [{ "name": "Team Cherry" }], + "platforms": [ + { "platform": { "name": "PC" } }, + { "platform": { "name": "macOS" } }, + { "platform": { "name": "Linux" } }, + { "platform": { "name": "Nintendo Switch" } } + ], + "genres": [{ "name": "Action" }, { "name": "Adventure" }, { "name": "Indie" }], + "released": "2017-02-24", + "metacritic": 90, + "background_image": "https://media.rawg.io/media/games/4cf/4cfc6b7f1850590a4634b08bfab308ab.jpg", + "description_raw": "Hollow Knight is a classically styled 2D action adventure crafted with painstaking detail. Explore twisting caverns, battle tainted creatures, and befriend bizarre bugs." +} diff --git a/tests/fixtures/steam-appdetails.json b/tests/fixtures/steam-appdetails.json new file mode 100644 index 0000000..f074a43 --- /dev/null +++ b/tests/fixtures/steam-appdetails.json @@ -0,0 +1,21 @@ +{ + "792100": { + "success": true, + "data": { + "type": "game", + "name": "7 Billion Humans", + "steam_appid": 792100, + "developers": ["Tomorrow Corporation"], + "publishers": ["Tomorrow Corporation"], + "genres": [ + { "id": "4", "description": "Casual" }, + { "id": "23", "description": "Indie" }, + { "id": "2", "description": "Strategy" } + ], + "release_date": { "coming_soon": false, "date": "2 Mar, 2018" }, + "metacritic": { "score": 79, "url": "https://www.metacritic.com/game/pc/7-billion-humans" }, + "header_image": "https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg", + "short_description": "From the creators of Human Resource Machine! Program a workforce of dumb humans to do your bidding in this fiendish puzzle game." + } + } +} diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts new file mode 100644 index 0000000..a1fd0d2 --- /dev/null +++ b/tests/library-game.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildGame, renderGame, parseSteamDate, gameSpec, type GameRecord } from 'packages/obsidian/src/library/game'; +import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; +import steamFixture from 'tests/fixtures/steam-appdetails.json'; +import rawgFixture from 'tests/fixtures/rawg-game.json'; + +const STEAM_DATA = steamFixture['792100'].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, + }; +} + +function ctxFor(fm: Record, body = '## My Notes\n\n'): LibraryNoteCtx { + return { frontmatter: fm, body, filename: '7 Billion Humans.md' }; +} + +describe('parseSteamDate', () => { + test('"2 Mar, 2018" -> 2018-03-02', () => { + expect(parseSteamDate('2 Mar, 2018')).toBe('2018-03-02'); + }); + test('"31 Dec, 2020" -> 2020-12-31', () => { + expect(parseSteamDate('31 Dec, 2020')).toBe('2020-12-31'); + }); + test('two-digit day, no leading zero needed', () => { + expect(parseSteamDate('15 Jan, 2019')).toBe('2019-01-15'); + }); + test('garbage -> empty string', () => { + expect(parseSteamDate('Coming soon')).toBe(''); + }); + test('empty -> empty string', () => { + expect(parseSteamDate('')).toBe(''); + }); + test('unrecognized month -> empty string', () => { + expect(parseSteamDate('2 Zzz, 2018')).toBe(''); + }); +}); + +describe('buildGame (steam source) field mapping', () => { + const r = buildGame('steam', STEAM_DATA, EMPTY_PREV); + test('core fields', () => { + expect(r.title).toBe('7 Billion Humans'); + expect(r.developer).toEqual(['Tomorrow Corporation']); + expect(r.publisher).toEqual(['Tomorrow Corporation']); + expect(r.genre).toEqual(['Casual', 'Indie', 'Strategy']); + expect(r.releaseDate).toBe('2018-03-02'); + expect(r.metacritic).toBe(79); + expect(r.steamAppid).toBe('792100'); + expect(r.poster).toBe('https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg'); + expect(r.url).toBe('https://store.steampowered.com/app/792100'); + }); + test('platforms default to [PC] regardless of Steam payload', () => { + expect(r.platforms).toEqual(['PC']); + }); + test('rawg_id empty when prev had none', () => { + expect(r.rawgId).toBe(''); + }); + test('rawg_id preserved from prev when steam-sourced (never dropped by steam enrich)', () => { + const r2 = buildGame('steam', STEAM_DATA, { rawg_id: '4200' }); + expect(r2.rawgId).toBe('4200'); + expect(r2.steamAppid).toBe('792100'); + }); + test('no metacritic block -> null', () => { + const r2 = buildGame('steam', { ...STEAM_DATA, metacritic: undefined }, EMPTY_PREV); + expect(r2.metacritic).toBeNull(); + }); + test('no header_image -> poster null', () => { + const r2 = buildGame('steam', { ...STEAM_DATA, header_image: undefined }, EMPTY_PREV); + expect(r2.poster).toBeNull(); + }); +}); + +describe('buildGame (rawg source) field mapping', () => { + const r = buildGame('rawg', rawgFixture, EMPTY_PREV); + test('core fields', () => { + expect(r.title).toBe('Hollow Knight'); + expect(r.developer).toEqual(['Team Cherry']); + expect(r.publisher).toEqual(['Team Cherry']); + expect(r.genre).toEqual(['Action', 'Adventure', 'Indie']); + expect(r.releaseDate).toBe('2017-02-24'); + expect(r.metacritic).toBe(90); + expect(r.rawgId).toBe('4200'); + expect(r.poster).toBe('https://media.rawg.io/media/games/4cf/4cfc6b7f1850590a4634b08bfab308ab.jpg'); + }); + test('platforms mapped from platform.name', () => { + expect(r.platforms).toEqual(['PC', 'macOS', 'Linux', 'Nintendo Switch']); + }); + test('steam_appid preserved from prev when rawg-sourced (never dropped by rawg enrich)', () => { + const r2 = buildGame('rawg', rawgFixture, { steam_appid: '792100' }); + expect(r2.steamAppid).toBe('792100'); + expect(r2.rawgId).toBe('4200'); + }); + test('steam_appid empty when prev had none', () => { + expect(r.steamAppid).toBe(''); + }); + test('url falls back to prev url when set', () => { + const r2 = buildGame('rawg', rawgFixture, { url: 'https://store.steampowered.com/app/999' }); + expect(r2.url).toBe('https://store.steampowered.com/app/999'); + }); + test('url falls back to rawg id-based link when no prev url', () => { + expect(r.url).toBe('https://rawg.io/games/4200'); + }); + test('no platforms array -> empty', () => { + const r2 = buildGame('rawg', { ...rawgFixture, platforms: undefined }, EMPTY_PREV); + expect(r2.platforms).toEqual([]); + }); +}); + +describe('buildGame user-field preservation', () => { + test('play_status defaults to Unplayed', () => { + expect(buildGame('steam', STEAM_DATA, EMPTY_PREV).playStatus).toBe('Unplayed'); + }); + test('play_status carried from prev', () => { + expect(buildGame('steam', STEAM_DATA, { play_status: 'Played' }).playStatus).toBe('Played'); + }); + test('rating/rating_stars carried from prev', () => { + const prev = { rating: '4', rating_stars: '⭐️⭐️⭐️⭐️' }; + const r = buildGame('steam', STEAM_DATA, prev); + expect(r.rating).toBe('4'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + }); +}); + +describe('buildGame skeleton conversion', () => { + test('played: true -> play_status Played', () => { + expect(buildGame('steam', STEAM_DATA, { played: 'true', personalRating: '' }).playStatus).toBe('Played'); + }); + test('played: false -> play_status Unplayed', () => { + expect(buildGame('steam', STEAM_DATA, { played: 'false', personalRating: '' }).playStatus).toBe('Unplayed'); + }); + test('personalRating 3 -> rating 3 + 3 stars', () => { + const r = buildGame('steam', STEAM_DATA, { played: 'false', personalRating: '3' }); + expect(r.rating).toBe('3'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️'); + }); + test('empty personalRating -> rating 0, no stars', () => { + const r = buildGame('steam', STEAM_DATA, { played: 'false', personalRating: '' }); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + }); +}); + +describe('renderGame golden', () => { + const RECORD: GameRecord = { + title: '7 Billion Humans', + playStatus: 'Played', + rating: '4', + ratingStars: '⭐️⭐️⭐️⭐️', + developer: ['Tomorrow Corporation'], + publisher: ['Tomorrow Corporation'], + platforms: ['PC'], + genre: ['Casual', 'Indie', 'Strategy'], + releaseDate: '2018-03-02', + metacritic: 79, + steamAppid: '792100', + rawgId: '', + poster: 'https://cdn.akamai.steamstatic.com/steam/apps/792100/header.jpg', + url: 'https://store.steampowered.com/app/792100', + description: 'From the creators of Human Resource Machine! Program a workforce of dumb humans to do your bidding in this fiendish puzzle game.', + }; + + test('matches canonical game fixture byte-for-byte', () => { + const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-game.md'), 'utf-8'); + expect(renderGame(RECORD, '', [])).toBe(expected); + }); + + test('custom section (Collection) placed after facts, before My Notes', () => { + const out = renderGame(RECORD, '', [{ heading: 'Collection', content: 'Part of [[Games]]' }]); + const platformsIdx = out.indexOf('**Platforms:**'); + const collectionIdx = out.indexOf('## Collection'); + const myNotesIdx = out.indexOf('## My Notes'); + expect(collectionIdx).toBeGreaterThan(platformsIdx); + expect(myNotesIdx).toBeGreaterThan(collectionIdx); + expect(out).toContain('## Collection\nPart of [[Games]]\n'); + }); + + test('My Notes content preserved', () => { + const out = renderGame(RECORD, 'played co-op with a friend'); + expect(out).toContain('## My Notes\n\nplayed co-op with a friend'); + }); + + test('no poster -> poster line omitted', () => { + const r = { ...RECORD, poster: null }; + expect(renderGame(r, '')).not.toContain('![poster'); + }); + + test('no description -> Synopsis section omitted', () => { + const r = { ...RECORD, description: '' }; + expect(renderGame(r, '')).not.toContain('## Synopsis'); + }); + + test('no developer/publisher/platforms -> fact lines omitted entirely', () => { + const r = { ...RECORD, developer: [], publisher: [], platforms: [] }; + const out = renderGame(r, ''); + expect(out).not.toContain('**Developer:**'); + expect(out).not.toContain('**Publisher:**'); + expect(out).not.toContain('**Platforms:**'); + }); + + test('no release_date/metacritic -> meta line is just **Game**', () => { + const r = { ...RECORD, releaseDate: '', metacritic: null }; + const out = renderGame(r, ''); + expect(out).toContain('\n**Game**\n'); + }); +}); + +describe('gameSpec.hasId', () => { + test('steam_appid set -> true', () => expect(gameSpec.hasId({ steam_appid: '792100' })).toBe(true)); + test('rawg_id set -> true', () => expect(gameSpec.hasId({ rawg_id: '4200' })).toBe(true)); + test('both set -> true', () => expect(gameSpec.hasId({ steam_appid: '792100', rawg_id: '4200' })).toBe(true)); + test('both empty -> false', () => expect(gameSpec.hasId({ steam_appid: '', rawg_id: '' })).toBe(false)); + test('both missing -> false', () => expect(gameSpec.hasId({})).toBe(false)); +}); + +describe('gameSpec.isActive', () => { + test('both ids empty -> active (needs first pass)', () => { + expect(gameSpec.isActive({})).toBe(true); + }); + test('steam_appid set -> static', () => { + expect(gameSpec.isActive({ steam_appid: '792100' })).toBe(false); + }); + test('rawg_id set -> static', () => { + expect(gameSpec.isActive({ rawg_id: '4200' })).toBe(false); + }); + test('both set -> static, regardless of play_status', () => { + expect(gameSpec.isActive({ steam_appid: '792100', rawg_id: '4200', play_status: 'Played' })).toBe(false); + }); +}); + +describe('gameSpec.resolve', () => { + test('steam store url in frontmatter -> appid parsed, zero network calls', async () => { + let httpCalls = 0; + const deps = makeDeps({ + http: async () => { + httpCalls++; + return {}; + }, + }); + const result = await gameSpec.resolve(ctxFor({ url: 'https://store.steampowered.com/app/792100/7_Billion_Humans/', title: '7 Billion Humans' }), deps); + expect(result).toEqual({ steam_appid: '792100' }); + expect(httpCalls).toBe(0); + }); + + test('no url match, steam storesearch unique exact -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ items: [{ id: 792100, name: '7 Billion Humans' }] }), + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toEqual({ steam_appid: '792100' }); + }); + + test('no exact match, sole steam result -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ items: [{ id: 999, name: 'Some Other Game' }] }), + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toEqual({ steam_appid: '999' }); + }); + + test('steam storesearch ambiguous, no key -> null, logged', async () => { + const deps = makeDeps({ + http: async () => ({ + items: [ + { id: 1, name: 'Foo' }, + { id: 2, name: 'Bar' }, + ], + }), + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true); + }); + + test('steam storesearch throws -> falls through to RAWG', async () => { + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('steampowered')) throw new Error('steam down'); + return { results: [{ id: 4200, name: '7 Billion Humans' }] }; + }, + getKey: () => 'rawgkey', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toEqual({ rawg_id: '4200' }); + expect(deps.logCalls.some(m => m.includes('storesearch'))).toBe(true); + }); + + test('no steam results, RAWG key present, unique exact -> rawg_id accepted', async () => { + const deps = makeDeps({ + http: async (url: string) => (url.includes('steampowered') ? { items: [] } : { results: [{ id: 4200, name: '7 Billion Humans' }] }), + getKey: () => 'rawgkey', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toEqual({ rawg_id: '4200' }); + }); + + test('no steam results, RAWG key missing -> null, logged, no RAWG call attempted', async () => { + let rawgCalled = false; + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('rawg')) rawgCalled = true; + return { items: [] }; + }, + getKey: () => '', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + expect(rawgCalled).toBe(false); + expect(deps.logCalls.some(m => m.toLowerCase().includes('key'))).toBe(true); + }); + + test('RAWG throws -> log, return null (no throw)', async () => { + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('steampowered')) return { items: [] }; + throw new Error('rawg down'); + }, + getKey: () => 'rawgkey', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + }); + + test('RAWG ambiguous -> null', async () => { + const deps = makeDeps({ + http: async (url: string) => + url.includes('steampowered') + ? { items: [] } + : { + results: [ + { id: 1, name: 'Foo' }, + { id: 2, name: 'Bar' }, + ], + }, + getKey: () => 'rawgkey', + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + }); + + test('no title, no filename fallback -> null (empty query guard)', async () => { + const deps = makeDeps(); + const ctx: LibraryNoteCtx = { frontmatter: {}, body: '', filename: '.md' }; + const result = await gameSpec.resolve(ctx, deps); + expect(result).toBeNull(); + }); +}); + +describe('gameSpec.sync — no ids', () => { + test('no steam_appid, no rawg_id -> null (needs resolve first)', async () => { + const deps = makeDeps(); + const result = await gameSpec.sync(ctxFor({}), deps); + expect(result).toBeNull(); + }); +}); + +describe('gameSpec.sync — steam enrich', () => { + test('success -> canonical fm rendered, platforms [PC], flipped always false', async () => { + const deps = makeDeps({ http: async () => steamFixture }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100', play_status: 'Played' }), deps); + expect(result).not.toBeNull(); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('type: game_item'); + expect(result!.content).toContain('steam_appid: 792100'); + expect(result!.content).toContain('platforms: [PC]'); + expect(result!.content).toContain('release_date: 2018-03-02'); + expect(result!.content).toContain('metacritic: 79'); + }); + + test('steam appdetails fetch throws -> log, return null (no throw)', async () => { + const deps = makeDeps({ + http: async () => { + throw new Error('network down'); + }, + }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); + + test('success:false, no rawg_id -> identity-guard: log, return null (no write)', async () => { + const deps = makeDeps({ http: async () => ({ '792100': { success: false } }) }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('792100'))).toBe(true); + }); + + test('success:false, rawg_id set but no key -> log, return null', async () => { + const deps = makeDeps({ http: async () => ({ '792100': { success: false } }) }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100', rawg_id: '4200' }), deps); + expect(result).toBeNull(); + }); + + test('success:false, rawg_id set + key -> falls to RAWG detail, steam_appid preserved in output', async () => { + const deps = makeDeps({ + http: async (url: string) => (url.includes('steampowered') ? { '792100': { success: false } } : rawgFixture), + getKey: () => 'rawgkey', + }); + const result = await gameSpec.sync(ctxFor({ steam_appid: '792100', rawg_id: '4200' }), deps); + expect(result).not.toBeNull(); + expect(result!.content).toContain('steam_appid: 792100'); + expect(result!.content).toContain('rawg_id: 4200'); + expect(result!.content).toContain('platforms: [PC, macOS, Linux, Nintendo Switch]'); + }); + + test('play_status/rating preserved through steam enrich', async () => { + const deps = makeDeps({ http: async () => steamFixture }); + const fm = { steam_appid: '792100', play_status: 'Played', rating: '4', rating_stars: '⭐️⭐️⭐️⭐️' }; + const result = await gameSpec.sync(ctxFor(fm), deps); + expect(result!.content).toContain('play_status: Played'); + expect(result!.content).toContain('rating: 4'); + expect(result!.content).toContain('rating_stars: ⭐️⭐️⭐️⭐️'); + }); + + test('My Notes content preserved through sync', async () => { + const deps = makeDeps({ http: async () => steamFixture }); + 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'); + }); +}); + +describe('gameSpec.sync — rawg enrich', () => { + test('rawg_id only, key present -> success, canonical fm rendered', async () => { + const deps = makeDeps({ http: async () => rawgFixture, getKey: () => 'rawgkey' }); + const result = await gameSpec.sync(ctxFor({ rawg_id: '4200' }), deps); + expect(result).not.toBeNull(); + expect(result!.content).toContain('rawg_id: 4200'); + expect(result!.content).toContain('type: game_item'); + }); + + test('rawg_id only, no key -> log, return null', async () => { + const deps = makeDeps({ getKey: () => '' }); + const result = await gameSpec.sync(ctxFor({ rawg_id: '4200' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); + + test('rawg_id only, fetch throws -> log, return null', async () => { + const deps = makeDeps({ + http: async () => { + throw new Error('rawg down'); + }, + getKey: () => 'rawgkey', + }); + const result = await gameSpec.sync(ctxFor({ rawg_id: '4200' }), deps); + expect(result).toBeNull(); + }); + + test('rawg detail returns malformed/empty data -> null, no write', async () => { + const deps = makeDeps({ http: async () => ({}), getKey: () => 'rawgkey' }); + const result = await gameSpec.sync(ctxFor({ rawg_id: '4200' }), deps); + expect(result).toBeNull(); + }); +}); From d2a694027082c02772d42472520e63c5695d1111 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 16:16:34 +0800 Subject: [PATCH 30/53] =?UTF-8?q?feat(library):=20comic=20spec=20=E2=80=94?= =?UTF-8?q?=20comic=20vine=20+=20issue=20flip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/obsidian/src/library/comic.ts | 261 ++++++++++++++ tests/fixtures/canonical-comic.md | 41 +++ tests/fixtures/comicvine-volume.json | 18 + tests/library-comic.test.ts | 449 +++++++++++++++++++++++++ 4 files changed, 769 insertions(+) create mode 100644 packages/obsidian/src/library/comic.ts create mode 100644 tests/fixtures/canonical-comic.md create mode 100644 tests/fixtures/comicvine-volume.json create mode 100644 tests/library-comic.test.ts diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts new file mode 100644 index 0000000..d797978 --- /dev/null +++ b/packages/obsidian/src/library/comic.ts @@ -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 { + 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 + * 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 { + 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

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(/]*>([\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): 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(`![poster|200](${r.poster})`, ''); + 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 { + 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): boolean { + return !!stripQuotes(fm['comicvine_id']); + }, + + isActive(fm: Record): 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 | 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 }; + }, +}; diff --git a/tests/fixtures/canonical-comic.md b/tests/fixtures/canonical-comic.md new file mode 100644 index 0000000..bf42c3c --- /dev/null +++ b/tests/fixtures/canonical-comic.md @@ -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 + +![poster|200](https://comicvine.gamespot.com/a/uploads/original/11/absolute-batman.jpg) + +**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 + diff --git a/tests/fixtures/comicvine-volume.json b/tests/fixtures/comicvine-volume.json new file mode 100644 index 0000000..949ea88 --- /dev/null +++ b/tests/fixtures/comicvine-volume.json @@ -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": "

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.

This paragraph exists only to verify the two-paragraph cap trims it from the rendered synopsis.

", + "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" + } +} diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts new file mode 100644 index 0000000..5540f41 --- /dev/null +++ b/tests/library-comic.test.ts @@ -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 = {}; + +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, + }; +} + +function ctxFor(fm: Record, 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 = '

First & foremost.

Second paragraph.

Third dropped.

'; + expect(htmlToPlainText(html)).toBe('First & foremost.\n\nSecond paragraph.'); + }); + test('no

tags -> whole string treated as one block', () => { + expect(htmlToPlainText('Bold and plain text.')).toBe('Bold and plain text.'); + }); + test('decodes < > " '  ', () => { + expect(htmlToPlainText('

<tag> "quoted" it's fine

')).toBe(` "quoted" it's fine`); + }); + test('empty string -> empty string', () => { + expect(htmlToPlainText('')).toBe(''); + }); + test('collapses internal whitespace from stripped tags', () => { + expect(htmlToPlainText('

Line one
Line two

')).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'); + }); +}); From c14da19c477cc8af8d7cae602617ee1cc53986fa Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 16:29:02 +0800 Subject: [PATCH 31/53] feat(library): rating line, manga finish-flip notify, game links, dedupe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/obsidian/src/library/book.ts | 23 +++---------- packages/obsidian/src/library/comic.ts | 27 ++------------- packages/obsidian/src/library/convert.ts | 29 ++++++++++++++++ packages/obsidian/src/library/game.ts | 20 +++++------ packages/obsidian/src/library/manga.ts | 28 ++------------- tests/fixtures/canonical-book.md | 1 + tests/fixtures/canonical-comic.md | 1 + tests/fixtures/canonical-game.md | 4 +++ tests/fixtures/canonical-manga.md | 1 + tests/library-book.test.ts | 9 +++++ tests/library-comic.test.ts | 9 +++++ tests/library-game.test.ts | 44 ++++++++++++++++++++++++ tests/library-manga.test.ts | 27 +++++++++++++-- 13 files changed, 140 insertions(+), 83 deletions(-) create mode 100644 packages/obsidian/src/library/convert.ts 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']); }); }); From 278f009a9b2a961eb56798330e1e925b928bc427 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 16:39:00 +0800 Subject: [PATCH 32/53] feat(library): generic per-type sync engine + resolve --- .../obsidian/src/library/LibraryEngine.ts | 119 ++++++ .../src/watchlist/WatchlistController.ts | 15 +- .../src/watchlist/patchFrontmatter.ts | 24 ++ tests/library-engine.test.ts | 392 ++++++++++++++++++ tests/watchlist-patch-frontmatter.test.ts | 34 ++ 5 files changed, 571 insertions(+), 13 deletions(-) create mode 100644 packages/obsidian/src/library/LibraryEngine.ts create mode 100644 packages/obsidian/src/watchlist/patchFrontmatter.ts create mode 100644 tests/library-engine.test.ts create mode 100644 tests/watchlist-patch-frontmatter.test.ts diff --git a/packages/obsidian/src/library/LibraryEngine.ts b/packages/obsidian/src/library/LibraryEngine.ts new file mode 100644 index 0000000..6fe135d --- /dev/null +++ b/packages/obsidian/src/library/LibraryEngine.ts @@ -0,0 +1,119 @@ +import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import { parseNote, stripQuotes } from 'packages/obsidian/src/watchlist/parse'; +import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter'; +import { withRateLimitRetry } from 'packages/obsidian/src/watchlist/SyncEngine'; + +export interface LibraryEngineDeps { + listNotes(): Promise<{ path: string }[]>; + readNote(path: string): Promise; + writeNote(path: string, content: string): Promise; + sleep(ms: number): Promise; + log(msg: string): void; + specDeps: SpecDeps; +} + +export interface LibraryReport { + scanned: number; + synced: number; + written: number; + skippedNoId: number; + skippedStatic: number; + skippedNoData: number; + flipped: string[]; + errors: { path: string; error: string }[]; +} + +export interface LibraryResolveReport { + resolved: string[]; + ambiguous: string[]; + errors: { path: string; error: string }[]; +} + +// Notes with `type: comicManga|book|game` are stock-skeleton entries (pre-conversion, all +// vault types collapse to these 3 legacy MediaType values) and must NOT be skipped -- the +// first sync converts them to their canonical `_item` shape. +const STOCK_SKELETON_TYPES = new Set(['comicManga', 'book', 'game']); + +function filenameOf(path: string): string { + return path.split('/').pop() ?? path; +} + +/** `_`-prefixed notes (dashboards/indexes) and non-entry notes (e.g. `type: folder_index`) are silently skipped. */ +function isSkippableNote(filename: string, fm: Record, itemType: string): boolean { + if (filename.startsWith('_')) return true; + const type = stripQuotes(fm['type']); + if (!type) return false; + if (type === itemType) return false; + if (STOCK_SKELETON_TYPES.has(type)) return false; + return true; +} + +export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngineDeps, opts: { full?: boolean; dryRun?: boolean } = {}): Promise { + const report: LibraryReport = { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, flipped: [], errors: [] }; + const notes = await deps.listNotes(); + for (const note of notes) { + report.scanned++; + try { + // Read immediately before parse/diff so mid-sync edits aren't clobbered by a stale snapshot. + const content = await deps.readNote(note.path); + const { frontmatter, body } = parseNote(content); + const filename = filenameOf(note.path); + if (isSkippableNote(filename, frontmatter, spec.itemType)) continue; + if (!spec.hasId(frontmatter)) { + report.skippedNoId++; + continue; + } + if (!opts.full && !spec.isActive(frontmatter)) { + report.skippedStatic++; + continue; + } + const ctx: LibraryNoteCtx = { frontmatter, body, filename }; + const result = await withRateLimitRetry(() => spec.sync(ctx, deps.specDeps), deps.sleep); + if (!result) { + report.skippedNoData++; + } else { + report.synced++; + if (result.flipped) report.flipped.push(note.path); + if (result.content !== content) { + report.written++; + if (!opts.dryRun) await deps.writeNote(note.path, result.content); + deps.log(`${opts.dryRun ? '[dry] ' : ''}updated ${note.path}`); + } + } + await deps.sleep(spec.throttleMs); + } catch (e) { + report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) }); + deps.log(`ERROR ${note.path}: ${String(e)}`); + } + } + return report; +} + +export async function libraryFolderResolve(spec: MediaTypeSpec, deps: LibraryEngineDeps, opts: { dryRun?: boolean } = {}): Promise { + const report: LibraryResolveReport = { resolved: [], ambiguous: [], errors: [] }; + const notes = await deps.listNotes(); + for (const note of notes) { + try { + const content = await deps.readNote(note.path); + const { frontmatter, body } = parseNote(content); + const filename = filenameOf(note.path); + if (isSkippableNote(filename, frontmatter, spec.itemType)) continue; + if (spec.hasId(frontmatter)) continue; // already resolved + const ctx: LibraryNoteCtx = { frontmatter, body, filename }; + const patch = await withRateLimitRetry(() => spec.resolve(ctx, deps.specDeps), deps.sleep); + if (!patch) { + report.ambiguous.push(note.path); + deps.log(`ambiguous/no match: ${note.path}`); + } else { + report.resolved.push(note.path); + deps.log(`${opts.dryRun ? '[dry] ' : ''}resolved ${note.path}`); + if (!opts.dryRun) await deps.writeNote(note.path, patchFrontmatter(content, patch, { defaultType: spec.itemType })); + } + await deps.sleep(spec.throttleMs); + } catch (e) { + report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) }); + deps.log(`ERROR ${note.path}: ${String(e)}`); + } + } + return report; +} diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts index 5928377..fd38794 100644 --- a/packages/obsidian/src/watchlist/WatchlistController.ts +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -4,6 +4,7 @@ import { syncFolder, withRateLimitRetry, TmdbRateLimitError, type SyncDeps, type import { fetchDetail, searchTitle, type HttpJsonFn } from 'packages/obsidian/src/watchlist/tmdb'; import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; import { parseNote, noteTmdbRef, stripQuotes } from 'packages/obsidian/src/watchlist/parse'; +import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter'; import { resolveNote } from 'packages/obsidian/src/watchlist/resolve'; export interface ResolveReport { @@ -13,18 +14,6 @@ export interface ResolveReport { errors: { path: string; error: string }[]; } -function patchFrontmatter(content: string, tmdbId: string, mediaType: string): string { - const insert = `tmdb_id: ${tmdbId}\nmedia_type: ${mediaType}`; - const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content); - if (fmMatch) { - const inner = fmMatch[1]; - const typeLine = /^type:.*$/m.exec(inner); - const newInner = typeLine ? inner.slice(0, typeLine.index + typeLine[0].length) + '\n' + insert + inner.slice(typeLine.index + typeLine[0].length) : insert + '\n' + inner; - return content.slice(0, fmMatch.index) + '---\n' + newInner + '\n---' + content.slice(fmMatch.index + fmMatch[0].length); - } - return `---\ntype: watchlist_item\n${insert}\n---\n\n` + content; -} - export function shouldNotifySync(quiet: boolean, written: number, errorCount: number): boolean { return !quiet || written > 0 || errorCount > 0; } @@ -146,7 +135,7 @@ export class WatchlistController { report.resolved++; const mediaType = result.isMovie ? 'Movie' : 'TV Series'; deps.log(`${dryRun ? '[dry] ' : ''}resolved ${note.path} -> tmdb_id ${result.tmdbId} (${result.matchedTitle})`); - if (!dryRun) await deps.writeNote(note.path, patchFrontmatter(content, result.tmdbId, mediaType)); + if (!dryRun) await deps.writeNote(note.path, patchFrontmatter(content, { tmdb_id: result.tmdbId, media_type: mediaType }, { defaultType: 'watchlist_item' })); } await deps.sleep(250); } catch (e) { diff --git a/packages/obsidian/src/watchlist/patchFrontmatter.ts b/packages/obsidian/src/watchlist/patchFrontmatter.ts new file mode 100644 index 0000000..7638c3b --- /dev/null +++ b/packages/obsidian/src/watchlist/patchFrontmatter.ts @@ -0,0 +1,24 @@ +/** + * Insert frontmatter key: value patches into note content, right after the `type:` line + * when frontmatter exists (or prepended before the rest of the frontmatter when there's + * no `type:` line). When the note has no frontmatter block at all, a new one is created, + * optionally seeded with `defaultType`. + * + * Shared by watchlist's resolveMissingIds and the library resolve engine. + */ +export function patchFrontmatter(content: string, patches: Record, opts: { defaultType?: string } = {}): string { + const insert = Object.entries(patches) + .map(([k, v]) => `${k}: ${v}`) + .join('\n'); + const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content); + if (fmMatch) { + const inner = fmMatch[1]; + const typeLine = /^type:.*$/m.exec(inner); + const newInner = typeLine + ? inner.slice(0, typeLine.index + typeLine[0].length) + '\n' + insert + inner.slice(typeLine.index + typeLine[0].length) + : insert + '\n' + inner; + return content.slice(0, fmMatch.index) + '---\n' + newInner + '\n---' + content.slice(fmMatch.index + fmMatch[0].length); + } + const typeLine = opts.defaultType ? `type: ${opts.defaultType}\n` : ''; + return `---\n${typeLine}${insert}\n---\n\n` + content; +} diff --git a/tests/library-engine.test.ts b/tests/library-engine.test.ts new file mode 100644 index 0000000..a1fcd24 --- /dev/null +++ b/tests/library-engine.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, test } from 'bun:test'; +import { libraryFolderSync, libraryFolderResolve, type LibraryEngineDeps } from 'packages/obsidian/src/library/LibraryEngine'; +import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { extractMyNotes } from 'packages/obsidian/src/watchlist/parse'; +import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; + +const FAKE_ITEM_TYPE = 'fake_item'; + +function renderFake(fm: Record, myNotes: string): string { + return `---\ntype: ${FAKE_ITEM_TYPE}\nfake_id: ${fm['fake_id'] ?? ''}\nstatus: ${fm['status'] ?? ''}\n---\n\n# Fake\n\n## My Notes\n\n${myNotes}\n`; +} + +interface FakeSpecOptions { + hasId?(fm: Record): boolean; + isActive?(fm: Record): boolean; + sync?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType; + resolve?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType; + throttleMs?: number; +} + +function makeFakeSpec(opts: FakeSpecOptions = {}) { + const syncCalls: LibraryNoteCtx[] = []; + const resolveCalls: LibraryNoteCtx[] = []; + const spec: MediaTypeSpec = { + typeName: 'manga', + itemType: FAKE_ITEM_TYPE, + folderSettingKey: 'fakeFolder', + enabledSettingKey: 'fakeEnabled', + throttleMs: opts.throttleMs ?? 111, + hasId: opts.hasId ?? (fm => !!fm['fake_id']), + isActive: opts.isActive ?? (fm => fm['status'] !== 'Done'), + resolve: async (ctx, deps) => { + resolveCalls.push(ctx); + return opts.resolve ? await opts.resolve(ctx, deps) : null; + }, + sync: async (ctx, deps) => { + syncCalls.push(ctx); + if (opts.sync) return await opts.sync(ctx, deps); + return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false }; + }, + }; + return { spec, syncCalls, resolveCalls }; +} + +const ACTIVE_NOTE = `--- +type: fake_item +fake_id: 42 +status: Active +--- + +# Raw Input + +## My Notes + +keep me +`; + +const DONE_NOTE = ACTIVE_NOTE.replace('status: Active', 'status: Done'); +const NO_ID_NOTE = ACTIVE_NOTE.replace('fake_id: 42', 'fake_id: '); + +function makeDeps(notes: { path: string; content: string }[], specDeps: Partial = {}): { deps: LibraryEngineDeps; writes: { path: string; content: string }[]; slept: number[]; contents: Map } { + const contents = new Map(notes.map(n => [n.path, n.content])); + const writes: { path: string; content: string }[] = []; + const slept: number[] = []; + const deps: LibraryEngineDeps = { + listNotes: async () => notes.map(n => ({ path: n.path })), + readNote: async path => contents.get(path)!, + writeNote: async (path, content) => { + writes.push({ path, content }); + }, + sleep: async ms => { + slept.push(ms); + }, + log: () => {}, + specDeps: { + http: async () => ({}), + httpText: async () => '', + getKey: () => '', + log: () => {}, + notify: () => {}, + ...specDeps, + }, + }; + return { deps, writes, slept, contents }; +} + +describe('libraryFolderSync: tiering', () => { + test('hasId false -> skippedNoId, sync not called', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const { deps } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.skippedNoId).toBe(1); + expect(syncCalls.length).toBe(0); + }); + + test('inactive + no full -> skippedStatic, sync not called', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const { deps } = makeDeps([{ path: 'A.md', content: DONE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.skippedStatic).toBe(1); + expect(syncCalls.length).toBe(0); + }); + + test('full:true overrides inactive tiering -> sync called', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const { deps } = makeDeps([{ path: 'A.md', content: DONE_NOTE }]); + const report = await libraryFolderSync(spec, deps, { full: true }); + expect(report.skippedStatic).toBe(0); + expect(syncCalls.length).toBe(1); + }); +}); + +describe('libraryFolderSync: diff-on-write + dryRun', () => { + test('diff-on-write: second pass on rendered output writes nothing', async () => { + const { spec } = makeFakeSpec(); + const { deps, writes } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(writes.length).toBe(1); + const rendered = writes[0].content; + + const second = makeDeps([{ path: 'A.md', content: rendered }]); + const report2 = await libraryFolderSync(spec, second.deps, {}); + expect(second.writes.length).toBe(0); + expect(report2.written).toBe(0); + }); + + test('dryRun: no writeNote call, but written count reflects what would change', async () => { + const { spec } = makeFakeSpec(); + const { deps, writes } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, { dryRun: true }); + expect(writes.length).toBe(0); + expect(report.written).toBe(1); + }); + + test('My Notes preserved through rewrite', async () => { + const { spec } = makeFakeSpec(); + const { deps, writes } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(writes[0].content).toContain('keep me'); + }); +}); + +describe('libraryFolderSync: flip bookkeeping', () => { + test('spec.sync flipped:true -> report.flipped includes path', async () => { + const { spec } = makeFakeSpec({ + sync: async ctx => ({ content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: true }), + }); + const { deps } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.flipped).toEqual(['A.md']); + }); + + test('spec.sync flipped:false -> report.flipped stays empty', async () => { + const { spec } = makeFakeSpec(); + const { deps } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.flipped).toEqual([]); + }); +}); + +describe('libraryFolderSync: skippedNoData (null sync result)', () => { + test('spec.sync returns null -> skippedNoData++, no write, no throw', async () => { + const { spec } = makeFakeSpec({ sync: async () => null }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.skippedNoData).toBe(1); + expect(report.synced).toBe(0); + expect(writes.length).toBe(0); + expect(report.errors.length).toBe(0); + }); +}); + +describe('libraryFolderSync: 429 retry', () => { + test('spec.sync throws TmdbRateLimitError once -> sleep(retryAfterMs) then retry succeeds', async () => { + let calls = 0; + const { spec } = makeFakeSpec({ + sync: async ctx => { + calls++; + if (calls === 1) { + const e = new TmdbRateLimitError('429'); + e.retryAfterMs = 1500; + throw e; + } + return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false }; + }, + }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(calls).toBe(2); + expect(slept).toContain(1500); + expect(report.errors.length).toBe(0); + }); +}); + +describe('libraryFolderSync: error isolation', () => { + test('one note errors, others still processed and counted', async () => { + let n = 0; + const { spec } = makeFakeSpec({ + sync: async ctx => { + n++; + if (n === 1) throw new Error('boom'); + return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false }; + }, + }); + const { deps } = makeDeps([ + { path: 'Bad.md', content: ACTIVE_NOTE }, + { path: 'Good.md', content: ACTIVE_NOTE }, + ]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.errors.length).toBe(1); + expect(report.errors[0].path).toBe('Bad.md'); + expect(report.synced).toBe(1); + expect(report.scanned).toBe(2); + }); +}); + +describe('libraryFolderSync: fresh-read mid-sync', () => { + test('content edited mid-sync is re-read fresh, not clobbered by early snapshot', async () => { + const { spec } = makeFakeSpec(); + const { deps, writes, contents } = makeDeps([ + { path: 'A.md', content: ACTIVE_NOTE }, + { path: 'B.md', content: ACTIVE_NOTE }, + ]); + let calls = 0; + const originalRead = deps.readNote; + deps.readNote = async path => { + calls++; + if (calls === 1) { + contents.set('B.md', ACTIVE_NOTE.replace('keep me', 'edited during sync')); + } + return originalRead(path); + }; + await libraryFolderSync(spec, deps, {}); + const bWrite = writes.find(w => w.path === 'B.md'); + expect(bWrite?.content).toContain('edited during sync'); + expect(bWrite?.content).not.toContain('keep me'); + }); +}); + +describe('libraryFolderSync: skip rules (underscore / non-entry / stock skeleton)', () => { + test('`_`-prefixed note skipped entirely, no counters bumped besides scanned', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const { deps } = makeDeps([{ path: '_Dashboard.md', content: '# dash' }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.scanned).toBe(1); + expect(report.skippedNoId).toBe(0); + expect(report.skippedStatic).toBe(0); + expect(report.skippedNoData).toBe(0); + expect(report.errors.length).toBe(0); + expect(syncCalls.length).toBe(0); + }); + + test('type: folder_index (non-entry, non-stock) skipped entirely', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const note = `---\ntype: folder_index\n---\n\n# Index\n`; + const { deps } = makeDeps([{ path: 'Index.md', content: note }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.scanned).toBe(1); + expect(report.skippedNoId).toBe(0); + expect(report.skippedStatic).toBe(0); + expect(syncCalls.length).toBe(0); + }); + + test('stock skeleton type (e.g. `type: book`) NOT skipped -> reaches hasId/sync gate', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const note = `---\ntype: book\nfake_id: 42\nstatus: Active\n---\n\nbody\n`; + const { deps } = makeDeps([{ path: 'Skeleton.md', content: note }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(syncCalls.length).toBe(1); + expect(report.synced).toBe(1); + }); + + test('stock skeleton without id yet still reaches hasId gate (skippedNoId, not silent skip)', async () => { + const { spec, syncCalls } = makeFakeSpec(); + const note = `---\ntype: game\nstatus: Active\n---\n\nbody\n`; + const { deps } = makeDeps([{ path: 'Skeleton.md', content: note }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(syncCalls.length).toBe(0); + expect(report.skippedNoId).toBe(1); + }); +}); + +describe('libraryFolderSync: throttle-after-success', () => { + test('sleeps spec.throttleMs after a processed note', async () => { + const { spec } = makeFakeSpec({ throttleMs: 777 }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(slept).toContain(777); + }); + + test('does not sleep for skippedStatic notes', async () => { + const { spec } = makeFakeSpec({ throttleMs: 777 }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: DONE_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(slept).not.toContain(777); + }); + + test('does not sleep for `_`-prefixed skipped notes', async () => { + const { spec } = makeFakeSpec({ throttleMs: 777 }); + const { deps, slept } = makeDeps([{ path: '_Dashboard.md', content: '# dash' }]); + await libraryFolderSync(spec, deps, {}); + expect(slept).not.toContain(777); + }); +}); + +describe('libraryFolderResolve', () => { + test('missing id -> spec.resolve patches written via patchFrontmatter', async () => { + const { spec } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(report.resolved).toEqual(['A.md']); + expect(report.ambiguous).toEqual([]); + expect(writes.length).toBe(1); + expect(writes[0].content).toContain('fake_id: 99'); + expect(writes[0].content).toContain('type: fake_item'); + }); + + test('ambiguous: spec.resolve returns null -> report.ambiguous, no write', async () => { + const { spec } = makeFakeSpec({ resolve: async () => null }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(report.ambiguous).toEqual(['A.md']); + expect(report.resolved).toEqual([]); + expect(writes.length).toBe(0); + }); + + test('hasId already true -> resolve() not called, note excluded from both lists', async () => { + const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { deps } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(resolveCalls.length).toBe(0); + expect(report.resolved).toEqual([]); + expect(report.ambiguous).toEqual([]); + }); + + test('dryRun: resolved counted but no writeNote call', async () => { + const { spec } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, { dryRun: true }); + expect(report.resolved).toEqual(['A.md']); + expect(writes.length).toBe(0); + }); + + test('error isolation: one note throws, other still resolved', async () => { + let n = 0; + const { spec } = makeFakeSpec({ + resolve: async () => { + n++; + if (n === 1) throw new Error('boom'); + return { fake_id: '99' }; + }, + }); + const { deps } = makeDeps([ + { path: 'Bad.md', content: NO_ID_NOTE }, + { path: 'Good.md', content: NO_ID_NOTE }, + ]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(report.errors.length).toBe(1); + expect(report.errors[0].path).toBe('Bad.md'); + expect(report.resolved).toEqual(['Good.md']); + }); + + test('`_`-prefixed / non-entry notes excluded from resolve pass', async () => { + const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const note = `---\ntype: folder_index\n---\n\n# Index\n`; + const { deps } = makeDeps([{ path: 'Index.md', content: note }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(resolveCalls.length).toBe(0); + expect(report.resolved).toEqual([]); + expect(report.ambiguous).toEqual([]); + }); + + test('429 retry: spec.resolve throws TmdbRateLimitError once then succeeds', async () => { + let calls = 0; + const { spec } = makeFakeSpec({ + resolve: async () => { + calls++; + if (calls === 1) { + const e = new TmdbRateLimitError('429'); + e.retryAfterMs = 1200; + throw e; + } + return { fake_id: '99' }; + }, + }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(calls).toBe(2); + expect(slept).toContain(1200); + expect(report.resolved).toEqual(['A.md']); + }); +}); diff --git a/tests/watchlist-patch-frontmatter.test.ts b/tests/watchlist-patch-frontmatter.test.ts new file mode 100644 index 0000000..3bb1904 --- /dev/null +++ b/tests/watchlist-patch-frontmatter.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter'; + +describe('patchFrontmatter', () => { + test('inserts patches right after the type: line when frontmatter exists', () => { + const content = '---\ntype: watchlist_item\ntitle: Loki\n---\n\nbody'; + const out = patchFrontmatter(content, { tmdb_id: '84958', media_type: 'TV Series' }); + expect(out).toBe('---\ntype: watchlist_item\ntmdb_id: 84958\nmedia_type: TV Series\ntitle: Loki\n---\n\nbody'); + }); + + test('no type: line -> patches prepended before existing frontmatter', () => { + const content = '---\ntitle: Loki\n---\n\nbody'; + const out = patchFrontmatter(content, { tmdb_id: '1' }); + expect(out).toBe('---\ntmdb_id: 1\ntitle: Loki\n---\n\nbody'); + }); + + test('no frontmatter at all -> creates block with defaultType', () => { + const content = '# Loki\n\nbody'; + const out = patchFrontmatter(content, { tmdb_id: '1' }, { defaultType: 'watchlist_item' }); + expect(out).toBe('---\ntype: watchlist_item\ntmdb_id: 1\n---\n\n# Loki\n\nbody'); + }); + + test('no frontmatter, no defaultType -> no type line inserted', () => { + const content = 'body only'; + const out = patchFrontmatter(content, { mal_id: '5' }); + expect(out).toBe('---\nmal_id: 5\n---\n\nbody only'); + }); + + test('multiple patch keys preserve insertion order', () => { + const content = '---\ntype: manga_item\n---\n'; + const out = patchFrontmatter(content, { mal_id: '5', mangadex_id: 'abc' }); + expect(out).toContain('mal_id: 5\nmangadex_id: abc'); + }); +}); From 2cce78d664526bddfad174fc930c7369c4641ea2 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 16:59:47 +0800 Subject: [PATCH 33/53] feat(library): controller, settings, commands, scheduler --- .../obsidian/src/library/LibraryController.ts | 211 ++++++++++++++++ packages/obsidian/src/main.ts | 34 ++- packages/obsidian/src/settings/Settings.ts | 68 +++++ tests/library-controller.test.ts | 237 ++++++++++++++++++ 4 files changed, 547 insertions(+), 3 deletions(-) create mode 100644 packages/obsidian/src/library/LibraryController.ts create mode 100644 tests/library-controller.test.ts diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts new file mode 100644 index 0000000..9a83a13 --- /dev/null +++ b/packages/obsidian/src/library/LibraryController.ts @@ -0,0 +1,211 @@ +import { Notice, TFile, TFolder } from 'obsidian'; +import { bookSpec } from 'packages/obsidian/src/library/book'; +import { comicSpec } from 'packages/obsidian/src/library/comic'; +import { gameSpec } from 'packages/obsidian/src/library/game'; +import { libraryFolderResolve, libraryFolderSync, type LibraryEngineDeps, type LibraryReport, type LibraryResolveReport } from 'packages/obsidian/src/library/LibraryEngine'; +import { mangaSpec } from 'packages/obsidian/src/library/manga'; +import type { HttpJsonFn, HttpTextFn, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; +import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { shouldNotifySync } from 'packages/obsidian/src/watchlist/WatchlistController'; + +/** Type <-> command-slug map, used by main.ts to register per-type commands and by the controller to iterate specs. */ +export const LIBRARY_TYPES: readonly { spec: MediaTypeSpec; slug: string }[] = [ + { spec: mangaSpec, slug: 'mangas' }, + { spec: bookSpec, slug: 'books' }, + { spec: gameSpec, slug: 'games' }, + { spec: comicSpec, slug: 'comics' }, +]; + +/** Specs whose enrichment needs a secretStorage-held API key (jikan/mangadex/openlibrary/rss/steam are keyless). */ +const KEY_REQUIREMENT: Partial> = { + game: 'rawg', + comic: 'comicvine', +}; + +const KEY_LABEL: Record<'rawg' | 'comicvine', string> = { + rawg: 'RAWG', + comicvine: 'Comic Vine', +}; + +function emptyReport(): LibraryReport { + return { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, flipped: [], errors: [] }; +} + +function emptyResolveReport(): LibraryResolveReport { + return { resolved: [], ambiguous: [], errors: [] }; +} + +export class LibraryController { + private syncing = false; + private specs: MediaTypeSpec[] = LIBRARY_TYPES.map(t => t.spec); + + constructor(private plugin: MediaDbPlugin) {} + + private notify(msg: string): void { + new Notice(msg); + } + + private getKey(name: 'rawg' | 'comicvine'): string { + const keyId = name === 'rawg' ? this.plugin.settings.RAWGAPIKeyId : this.plugin.settings.ComicVineKeyId; + const key = keyId ? this.plugin.app.secretStorage.getSecret(keyId) : null; + return key ?? ''; + } + + private folderFor(spec: MediaTypeSpec): string { + return (this.plugin.settings as unknown as Record)[spec.folderSettingKey]; + } + + private enabledFor(spec: MediaTypeSpec): boolean { + return (this.plugin.settings as unknown as Record)[spec.enabledSettingKey]; + } + + // Duplicated (not extracted) from WatchlistController.makeHttp -- watchlist code stays untouched per plan. + // Kept minimal + generic (no TMDB-specific wording) since it's shared across all library APIs. + private makeHttp(): HttpJsonFn { + return async (url: string, headers: Record): Promise => { + const res = await obsidianFetch(new Request(url, { headers })); + if (res.status === 429) { + const err = new TmdbRateLimitError(`Library API 429 for ${url}`); + const ra = Number(res.headers.get('retry-after')); + err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000; + throw err; + } + if (res.status !== 200) throw new Error(`Library API ${res.status} for ${url}`); + return await res.json(); + }; + } + + private makeHttpText(): HttpTextFn { + return async (url: string, headers: Record): Promise => { + const res = await obsidianFetch(new Request(url, { headers })); + if (res.status === 429) { + const err = new TmdbRateLimitError(`Library API 429 for ${url}`); + const ra = Number(res.headers.get('retry-after')); + err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000; + throw err; + } + if (res.status !== 200) throw new Error(`Library API ${res.status} for ${url}`); + return await res.text(); + }; + } + + private makeSpecDeps(): SpecDeps { + return { + http: this.makeHttp(), + httpText: this.makeHttpText(), + getKey: name => this.getKey(name), + log: msg => console.log(`[media-db-library] ${msg}`), + notify: msg => this.notify(msg), + }; + } + + private makeDeps(spec: MediaTypeSpec): LibraryEngineDeps { + const { app } = this.plugin; + const folderPath = this.folderFor(spec); + const folder = app.vault.getAbstractFileByPath(folderPath); + return { + listNotes: async () => { + if (!(folder instanceof TFolder)) throw new Error(`Library folder not found: ${folderPath}`); + const files = folder.children.filter((f): f is TFile => f instanceof TFile && f.extension === 'md' && !f.name.startsWith('_')); + return files.map(f => ({ path: f.path })); + }, + readNote: async path => { + const f = app.vault.getAbstractFileByPath(path); + if (!(f instanceof TFile)) throw new Error(`Library note not found: ${path}`); + return await app.vault.read(f); + }, + writeNote: async (path, content) => { + const f = app.vault.getAbstractFileByPath(path); + if (f instanceof TFile) await app.vault.modify(f, content); + }, + sleep: ms => new Promise(r => setTimeout(r, ms)), + log: msg => console.log(`[media-db-library] ${msg}`), + specDeps: this.makeSpecDeps(), + }; + } + + /** Per-note key skipping already happens inside the spec (log+skip); this only surfaces one Notice per sync run naming the affected type. */ + private warnMissingKey(spec: MediaTypeSpec): void { + const req = KEY_REQUIREMENT[spec.typeName]; + if (!req || this.getKey(req)) return; + this.notify(`Library sync: ${KEY_LABEL[req]} API key not configured — ${spec.typeName} enrichment skipped (Media DB Sync settings).`); + } + + private async runSync(spec: MediaTypeSpec, full: boolean, dryRun: boolean, quiet: boolean): Promise { + this.warnMissingKey(spec); + const deps = this.makeDeps(spec); + const report = await libraryFolderSync(spec, deps, { full, dryRun }); + const mode = dryRun ? 'DRY-RUN ' : ''; + if (shouldNotifySync(quiet, report.written, report.errors.length)) { + this.notify( + `Library ${mode}sync (${spec.typeName}): ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + } + return report; + } + + async syncType(spec: MediaTypeSpec, full: boolean, dryRun = false, quiet = false): Promise { + if (this.syncing) { + this.notify('Library sync already running'); + return emptyReport(); + } + this.syncing = true; + try { + return await this.runSync(spec, full, dryRun, quiet); + } finally { + this.syncing = false; + } + } + + async resolveType(spec: MediaTypeSpec, dryRun = false): Promise { + if (this.syncing) { + this.notify('Library sync already running'); + return emptyResolveReport(); + } + this.syncing = true; + try { + const deps = this.makeDeps(spec); + const report = await libraryFolderResolve(spec, deps, { dryRun }); + const mode = dryRun ? 'DRY-RUN ' : ''; + this.notify( + `Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + return report; + } finally { + this.syncing = false; + } + } + + async syncAll(full: boolean, quiet = false): Promise { + if (this.syncing) { + this.notify('Library sync already running'); + return; + } + this.syncing = true; + try { + for (const spec of this.specs) { + if (!this.enabledFor(spec)) continue; + await this.runSync(spec, full, false, quiet); + } + if (!full) { + this.plugin.settings.libraryLastSync = Date.now(); + await this.plugin.saveSettings(); + } + } finally { + this.syncing = false; + } + } + + async maybeCatchUp(): Promise { + const s = this.plugin.settings; + if (!this.specs.some(spec => this.enabledFor(spec))) return; + const due = s.libraryLastSync + s.watchlistSyncIntervalHours * 3600_000; + if (Date.now() >= due) { + await this.syncAll(false, true).catch(e => console.error('[media-db-library] scheduled sync failed', e)); + } + } +} diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index b80d72d..3c2d6da 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -16,6 +16,7 @@ import { TMDBSeasonAPI } from 'packages/obsidian/src/api/apis/TMDBSeasonAPI'; import { TMDBSeriesAPI } from 'packages/obsidian/src/api/apis/TMDBSeriesAPI'; import { VNDBAPI } from 'packages/obsidian/src/api/apis/VNDBAPI'; import { WikipediaAPI } from 'packages/obsidian/src/api/apis/WikipediaAPI'; +import { LibraryController, LIBRARY_TYPES } from 'packages/obsidian/src/library/LibraryController'; import type { LegacyApiKeyEntry } from 'packages/obsidian/src/modals/LegacyApiKeysModal'; import { LegacyApiKeysModal } from 'packages/obsidian/src/modals/LegacyApiKeysModal'; import { PropertyMapper } from 'packages/obsidian/src/settings/PropertyMapper'; @@ -46,6 +47,7 @@ export default class MediaDbPlugin extends Plugin { dateFormatter!: DateFormatter; errorReporter!: ErrorReporter; watchlist!: WatchlistController; + library!: LibraryController; async onload(): Promise { this.mediaTypeManager = new MediaTypeManager(); @@ -64,10 +66,13 @@ export default class MediaDbPlugin extends Plugin { this.registerCommands(); this.watchlist = new WatchlistController(this); - // catch-up shortly after startup (let vault index settle), then hourly due-check - const catchUpTimeout = window.setTimeout(() => void this.watchlist.maybeCatchUp(), 30_000); + this.library = new LibraryController(this); + // catch-up shortly after startup (let vault index settle), then hourly due-check. + // library catch-up runs after watchlist's, sharing the same timers (no new timers). + const catchUp = () => void this.watchlist.maybeCatchUp().then(() => this.library.maybeCatchUp()); + const catchUpTimeout = window.setTimeout(catchUp, 30_000); this.register(() => window.clearTimeout(catchUpTimeout)); - this.registerInterval(window.setInterval(() => void this.watchlist.maybeCatchUp(), 3600_000)); + this.registerInterval(window.setInterval(catchUp, 3600_000)); } onunload(): void {} @@ -197,6 +202,29 @@ export default class MediaDbPlugin extends Plugin { name: 'Watchlist: resolve missing TMDB ids', callback: () => void this.watchlist.resolveMissingIds().catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), }); + + for (const { spec, slug } of LIBRARY_TYPES) { + this.addCommand({ + id: `library-sync-${slug}`, + name: `Library: sync ${slug} now`, + callback: () => void this.library.syncType(spec, false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + }); + this.addCommand({ + id: `library-resolve-${slug}`, + name: `Library: resolve ${slug} ids`, + callback: () => void this.library.resolveType(spec).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + }); + this.addCommand({ + id: `library-dry-run-${slug}`, + name: `Library: dry-run ${slug} full sync`, + callback: () => void this.library.syncType(spec, true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + }); + } + this.addCommand({ + id: 'library-sync-all', + name: 'Library: sync all', + callback: () => void this.library.syncAll(false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + }); } private getLegacyApiKeyEntries(diskSettings: Record): LegacyApiKeyEntry[] { diff --git a/packages/obsidian/src/settings/Settings.ts b/packages/obsidian/src/settings/Settings.ts index 06554f0..034e93d 100644 --- a/packages/obsidian/src/settings/Settings.ts +++ b/packages/obsidian/src/settings/Settings.ts @@ -124,6 +124,16 @@ export interface MediaDbPluginSettings { watchlistSyncIntervalHours: number; watchlistLastSync: number; + libraryMangaEnabled: boolean; + libraryMangaFolder: string; + libraryBookEnabled: boolean; + libraryBookFolder: string; + libraryGameEnabled: boolean; + libraryGameFolder: string; + libraryComicEnabled: boolean; + libraryComicFolder: string; + libraryLastSync: number; + // DEPRECATED: Use propertyMappingModels instead moviePropertyConversionRules: string; seriesPropertyConversionRules: string; @@ -388,6 +398,16 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { watchlistSyncIntervalHours: 24, watchlistLastSync: 0, + libraryMangaEnabled: false, + libraryMangaFolder: 'Mangas', + libraryBookEnabled: false, + libraryBookFolder: 'Books', + libraryGameEnabled: false, + libraryGameFolder: 'Games', + libraryComicEnabled: false, + libraryComicFolder: 'Comics', + libraryLastSync: 0, + // DEPRECATED moviePropertyConversionRules: '', seriesPropertyConversionRules: '', @@ -791,6 +811,54 @@ export class MediaDbSettingTab extends PluginSettingTab { }), ); + // MARK: Library sync + const libraryGroup = new SettingGroup(containerEl); + libraryGroup.setHeading('Library sync'); + + const libraryTypeSettings: { label: string; enabledKey: keyof MediaDbPluginSettings; folderKey: keyof MediaDbPluginSettings; desc: string }[] = [ + { label: 'Manga', enabledKey: 'libraryMangaEnabled', folderKey: 'libraryMangaFolder', desc: 'Jikan (MyAnimeList) + MangaDex/RSS chapter tracking.' }, + { label: 'Book', enabledKey: 'libraryBookEnabled', folderKey: 'libraryBookFolder', desc: 'Open Library enrichment.' }, + { label: 'Game', enabledKey: 'libraryGameEnabled', folderKey: 'libraryGameFolder', desc: 'Steam + RAWG enrichment. Uses the RAWG API key configured above.' }, + { label: 'Comic', enabledKey: 'libraryComicEnabled', folderKey: 'libraryComicFolder', desc: 'Comic Vine enrichment. Uses the Comic Vine API key configured above.' }, + ]; + + for (const { label, enabledKey, folderKey, desc } of libraryTypeSettings) { + libraryGroup.addSetting( + setting => + void setting + .setName(`Enable ${label.toLowerCase()} library sync`) + .setDesc(`Periodically sync ${label.toLowerCase()} notes against their source APIs. ${desc}`) + .addToggle(cb => { + cb.setValue(this.plugin.settings[enabledKey] as boolean).onChange(data => { + (this.plugin.settings[enabledKey] as boolean) = data; + void this.plugin.saveSettings(); + }); + }), + ); + + libraryGroup.addSetting( + setting => + void setting + .setName(`${label} folder`) + .setDesc(`Folder containing ${label.toLowerCase()} notes.`) + .addSearch(cb => { + const suggester = new FolderSuggest(this.app, cb.inputEl); + suggester.onSelect(folder => { + cb.setValue(folder.path); + (this.plugin.settings[folderKey] as string) = folder.path; + void this.plugin.saveSettings(); + suggester.close(); + }); + cb.setPlaceholder(DEFAULT_SETTINGS[folderKey] as string) + .setValue(this.plugin.settings[folderKey] as string) + .onChange(data => { + (this.plugin.settings[folderKey] as string) = data; + void this.plugin.saveSettings(); + }); + }), + ); + } + // MARK: Media type settings // Create a map to store APIs for each media type diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts new file mode 100644 index 0000000..8f9cbfa --- /dev/null +++ b/tests/library-controller.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from 'bun:test'; +import { bookSpec } from 'packages/obsidian/src/library/book'; +import { gameSpec } from 'packages/obsidian/src/library/game'; +import { LibraryController } from 'packages/obsidian/src/library/LibraryController'; +import type { LibraryEngineDeps } from 'packages/obsidian/src/library/LibraryEngine'; +import { mangaSpec } from 'packages/obsidian/src/library/manga'; + +function fakePlugin( + overrides: Partial<{ + mangaEnabled: boolean; + bookEnabled: boolean; + gameEnabled: boolean; + comicEnabled: boolean; + last: number; + hours: number; + rawgKeyId: string; + comicvineKeyId: string; + }> = {}, +) { + const secrets: Record = {}; + if (overrides.rawgKeyId) secrets[overrides.rawgKeyId] = 'rawg-secret'; + if (overrides.comicvineKeyId) secrets[overrides.comicvineKeyId] = 'cv-secret'; + return { + settings: { + libraryMangaEnabled: overrides.mangaEnabled ?? true, + libraryMangaFolder: 'Mangas', + libraryBookEnabled: overrides.bookEnabled ?? true, + libraryBookFolder: 'Books', + libraryGameEnabled: overrides.gameEnabled ?? true, + libraryGameFolder: 'Games', + libraryComicEnabled: overrides.comicEnabled ?? true, + libraryComicFolder: 'Comics', + libraryLastSync: overrides.last ?? 0, + watchlistSyncIntervalHours: overrides.hours ?? 24, + RAWGAPIKeyId: overrides.rawgKeyId ?? '', + ComicVineKeyId: overrides.comicvineKeyId ?? '', + }, + saveSettings: async () => {}, + app: { + secretStorage: { getSecret: (id: string) => secrets[id] ?? null }, + vault: {}, + }, + } as any; +} + +describe('maybeCatchUp', () => { + test('overdue → syncs', async () => { + const c = new LibraryController(fakePlugin({ last: 0 })); + let called = false; + (c as any).syncAll = async () => { + called = true; + }; + await c.maybeCatchUp(); + expect(called).toBe(true); + }); + + test('recent sync → no call', async () => { + const c = new LibraryController(fakePlugin({ last: Date.now() })); + let called = false; + (c as any).syncAll = async () => { + called = true; + }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); + + test('all types disabled → no call', async () => { + const c = new LibraryController(fakePlugin({ mangaEnabled: false, bookEnabled: false, gameEnabled: false, comicEnabled: false, last: 0 })); + let called = false; + (c as any).syncAll = async () => { + called = true; + }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); +}); + +function fakeSpecDeps() { + return { http: async () => ({}), httpText: async () => '', getKey: () => '', log: () => {}, notify: () => {} }; +} + +function deferredDeps(): { deps: LibraryEngineDeps; listNotesCalls: () => number; release: () => void } { + let listNotesCalls = 0; + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const deps: LibraryEngineDeps = { + listNotes: async () => { + listNotesCalls++; + await gate; + return []; + }, + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + return { deps, listNotesCalls: () => listNotesCalls, release }; +} + +describe('concurrency guard', () => { + test('overlapping syncType calls: second short-circuits while first is in flight', async () => { + const c = new LibraryController(fakePlugin()); + const { deps, listNotesCalls, release } = deferredDeps(); + (c as any).makeDeps = () => deps; + + const first = c.syncType(mangaSpec, false); + const second = await c.syncType(mangaSpec, false); + + expect(listNotesCalls()).toBe(1); + expect(second.scanned).toBe(0); + expect(second.written).toBe(0); + + release(); + const firstResult = await first; + expect(firstResult.scanned).toBe(0); + }); + + test('syncType in flight blocks resolveType (shared guard)', async () => { + const c = new LibraryController(fakePlugin()); + const { deps, release } = deferredDeps(); + (c as any).makeDeps = () => deps; + + const first = c.syncType(mangaSpec, false); + const resolveResult = await c.resolveType(bookSpec); + + expect(resolveResult.resolved.length).toBe(0); + expect(resolveResult.ambiguous.length).toBe(0); + + release(); + await first; + }); + + test('syncType in flight blocks syncAll (shared guard)', async () => { + const c = new LibraryController(fakePlugin()); + const { deps, release } = deferredDeps(); + (c as any).makeDeps = () => deps; + + const first = c.syncType(mangaSpec, false); + await c.syncAll(false, true); // should short-circuit, not throw + + release(); + await first; + }); + + test('flag resets after completion → next call runs normally', async () => { + const c = new LibraryController(fakePlugin()); + let listNotesCalls = 0; + (c as any).makeDeps = () => ({ + listNotes: async () => { + listNotesCalls++; + return []; + }, + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }); + + await c.syncType(mangaSpec, false); + await c.syncType(mangaSpec, false); + + expect(listNotesCalls).toBe(2); + }); +}); + +describe('missing API key handling', () => { + test('missing RAWG + Comic Vine keys → one notice each for game/comic, manga/book unaffected', async () => { + const c = new LibraryController(fakePlugin()); // no rawgKeyId/comicvineKeyId configured + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const calledTypes: string[] = []; + (c as any).makeDeps = (spec: { typeName: string }) => { + calledTypes.push(spec.typeName); + return { + listNotes: async () => [], + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + }; + + await c.syncAll(false, true); + + expect(calledTypes.sort()).toEqual(['book', 'comic', 'game', 'manga']); + const keyNotices = notices.filter(m => m.includes('API key not configured')); + expect(keyNotices.length).toBe(2); + expect(keyNotices.some(m => m.includes('RAWG') && m.includes('game'))).toBe(true); + expect(keyNotices.some(m => m.includes('Comic Vine') && m.includes('comic'))).toBe(true); + }); + + test('keys present → no missing-key notices', async () => { + const c = new LibraryController(fakePlugin({ rawgKeyId: 'rid', comicvineKeyId: 'cvid' })); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + (c as any).makeDeps = () => ({ + listNotes: async () => [], + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }); + + await c.syncAll(false, true); + + expect(notices.filter(m => m.includes('API key not configured')).length).toBe(0); + }); + + test('single-type sync (game) with missing RAWG key still notifies once and runs', async () => { + const c = new LibraryController(fakePlugin({ rawgKeyId: '' })); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + let ran = false; + (c as any).makeDeps = () => { + ran = true; + return { + listNotes: async () => [], + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + }; + + await c.syncType(gameSpec, false); + + expect(ran).toBe(true); + expect(notices.filter(m => m.includes('API key not configured')).length).toBe(1); + }); +}); From 6225231d6747c87afa36610bd0f690ee563f67f7 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 21:40:21 +0800 Subject: [PATCH 34/53] fix(library): suppress missing-key notices on quiet scheduled syncs --- .../obsidian/src/library/LibraryController.ts | 11 +++-- tests/library-controller.test.ts | 43 ++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 9a83a13..fba6a85 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -127,14 +127,19 @@ export class LibraryController { } /** Per-note key skipping already happens inside the spec (log+skip); this only surfaces one Notice per sync run naming the affected type. */ - private warnMissingKey(spec: MediaTypeSpec): void { + private warnMissingKey(spec: MediaTypeSpec, quiet: boolean): void { const req = KEY_REQUIREMENT[spec.typeName]; if (!req || this.getKey(req)) return; - this.notify(`Library sync: ${KEY_LABEL[req]} API key not configured — ${spec.typeName} enrichment skipped (Media DB Sync settings).`); + const msg = `Library sync: ${KEY_LABEL[req]} API key not configured — ${spec.typeName} enrichment skipped (Media DB Sync settings).`; + if (quiet) { + console.log(`[media-db-library] ${msg}`); + } else { + this.notify(msg); + } } private async runSync(spec: MediaTypeSpec, full: boolean, dryRun: boolean, quiet: boolean): Promise { - this.warnMissingKey(spec); + this.warnMissingKey(spec, quiet); const deps = this.makeDeps(spec); const report = await libraryFolderSync(spec, deps, { full, dryRun }); const mode = dryRun ? 'DRY-RUN ' : ''; diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index 8f9cbfa..f61697e 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -168,7 +168,46 @@ describe('concurrency guard', () => { }); describe('missing API key handling', () => { - test('missing RAWG + Comic Vine keys → one notice each for game/comic, manga/book unaffected', async () => { + test('missing RAWG + Comic Vine keys + quiet sync → zero notices, logged only', async () => { + const c = new LibraryController(fakePlugin()); // no rawgKeyId/comicvineKeyId configured + const notices: string[] = []; + const logs: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const calledTypes: string[] = []; + (c as any).makeDeps = (spec: { typeName: string }) => { + calledTypes.push(spec.typeName); + return { + listNotes: async () => [], + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + }; + + const origLog = console.log; + console.log = (msg: string) => { + logs.push(msg); + origLog(msg); + }; + + try { + await c.syncAll(false, true); + } finally { + console.log = origLog; + } + + expect(calledTypes.sort()).toEqual(['book', 'comic', 'game', 'manga']); + const keyNotices = notices.filter(m => m.includes('API key not configured')); + expect(keyNotices.length).toBe(0); + const keyLogs = logs.filter(m => m.includes('API key not configured')); + expect(keyLogs.length).toBe(2); + expect(keyLogs.some(m => m.includes('RAWG') && m.includes('game'))).toBe(true); + expect(keyLogs.some(m => m.includes('Comic Vine') && m.includes('comic'))).toBe(true); + }); + + test('missing RAWG + Comic Vine keys + manual (non-quiet) sync → one notice each for game/comic', async () => { const c = new LibraryController(fakePlugin()); // no rawgKeyId/comicvineKeyId configured const notices: string[] = []; (c as any).notify = (msg: string) => notices.push(msg); @@ -185,7 +224,7 @@ describe('missing API key handling', () => { }; }; - await c.syncAll(false, true); + await c.syncAll(false, false); expect(calledTypes.sort()).toEqual(['book', 'comic', 'game', 'manga']); const keyNotices = notices.filter(m => m.includes('API key not configured')); From 1d5537742f4cd881ba65e9ca2c1483954a283ff2 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 22:08:09 +0800 Subject: [PATCH 35/53] fix(library): first-pass enrich gate skips post-resolve skeletons isActive() for book/game/comic only checked for a resolved id, so a note that had run resolve() but never a real sync() (no read_status/ play_status/status field yet) was treated as static and never enriched on subsequent runs. Now also requires the status-analog field canonical render always writes; missing it means sync() hasn't actually produced output yet. --- packages/obsidian/src/library/book.ts | 9 ++++++--- packages/obsidian/src/library/comic.ts | 3 +++ packages/obsidian/src/library/game.ts | 9 ++++++--- tests/library-book.test.ts | 6 ++++++ tests/library-comic.test.ts | 6 ++++++ tests/library-game.test.ts | 19 ++++++++++++++----- 6 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 8e53743..0ece284 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -126,9 +126,12 @@ export const bookSpec: MediaTypeSpec = { }, isActive(fm: Record): boolean { - // static once enriched; no chapter/issue sources, no flips -- only a missing - // olid ever makes a book note active again (a full sync bypasses this check) - return !stripQuotes(fm['olid']); + // active until the first real enrich has actually run: missing olid (never + // resolved) OR missing read_status (post-resolve skeleton -- canonical render + // always writes read_status, so its absence means sync() hasn't produced + // canonical output yet). Static once both are present; no chapter/issue sources, + // no flips -- a full sync bypasses this check regardless. + return !stripQuotes(fm['olid']) || !stripQuotes(fm['read_status']); }, async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index 6695792..279cef6 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -158,6 +158,9 @@ export const comicSpec: MediaTypeSpec = { isActive(fm: Record): boolean { if (!stripQuotes(fm['comicvine_id'])) return true; // never enriched -> needs first pass + // post-resolve skeleton -- canonical render always writes status, so its absence + // means sync() hasn't produced canonical output yet + if (!stripQuotes(fm['status'])) return true; if (stripQuotes(fm['status']) === 'Ongoing') return true; if (stripQuotes(fm['read_status']) === 'Reading') return true; return false; diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts index f492b63..6c8944f 100644 --- a/packages/obsidian/src/library/game.ts +++ b/packages/obsidian/src/library/game.ts @@ -192,9 +192,12 @@ export const gameSpec: MediaTypeSpec = { }, isActive(fm: Record): boolean { - // static once either id is set; no chapter/issue-style automation exists for games -- - // only missing both ids ever makes a game note active again (full sync bypasses this) - return !stripQuotes(fm['steam_appid']) && !stripQuotes(fm['rawg_id']); + // active until the first real enrich has actually run: both ids empty (never + // resolved) OR missing play_status (post-resolve skeleton -- canonical render + // always writes play_status, so its absence means sync() hasn't produced canonical + // output yet). Static once an id + play_status are present; no chapter/issue-style + // automation exists for games -- a full sync bypasses this check regardless. + return (!stripQuotes(fm['steam_appid']) && !stripQuotes(fm['rawg_id'])) || !stripQuotes(fm['play_status']); }, async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index aea1715..b8d348c 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -194,6 +194,12 @@ describe('bookSpec.isActive', () => { test('olid set + Unread -> static', () => { expect(bookSpec.isActive({ olid: 'OL1168083W', read_status: 'Unread' })).toBe(false); }); + test('olid set, read_status missing (post-resolve skeleton) -> active (C1)', () => { + expect(bookSpec.isActive({ olid: 'OL1168083W' })).toBe(true); + }); + test('canonical enriched note (olid + read_status both present) -> static', () => { + expect(bookSpec.isActive({ olid: 'OL1168083W', read_status: 'Unread' })).toBe(false); + }); }); function ctxFor(fm: Record, body = '## My Notes\n\n'): LibraryNoteCtx { diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index 29dc7d2..f0850a6 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -235,6 +235,12 @@ describe('comicSpec.isActive', () => { test('status Ended + read_status Unread -> static', () => { expect(comicSpec.isActive({ comicvine_id: '195824', status: 'Ended', read_status: 'Unread' })).toBe(false); }); + test('comicvine_id set, status missing (post-resolve skeleton) -> active (C1)', () => { + expect(comicSpec.isActive({ comicvine_id: '195824' })).toBe(true); + }); + test('canonical enriched note (status Ended, not Ongoing) -> static', () => { + expect(comicSpec.isActive({ comicvine_id: '195824', status: 'Ended', read_status: 'Read' })).toBe(false); + }); }); describe('comicSpec.resolve', () => { diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index f9a994c..8b5d593 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -266,13 +266,22 @@ describe('gameSpec.isActive', () => { test('both ids empty -> active (needs first pass)', () => { expect(gameSpec.isActive({})).toBe(true); }); - test('steam_appid set -> static', () => { - expect(gameSpec.isActive({ steam_appid: '792100' })).toBe(false); + test('steam_appid set + play_status set -> static', () => { + expect(gameSpec.isActive({ steam_appid: '792100', play_status: 'Unplayed' })).toBe(false); }); - test('rawg_id set -> static', () => { - expect(gameSpec.isActive({ rawg_id: '4200' })).toBe(false); + test('rawg_id set + play_status set -> static', () => { + expect(gameSpec.isActive({ rawg_id: '4200', play_status: 'Unplayed' })).toBe(false); }); - test('both set -> static, regardless of play_status', () => { + test('both ids set -> static, regardless of play_status', () => { + expect(gameSpec.isActive({ steam_appid: '792100', rawg_id: '4200', play_status: 'Played' })).toBe(false); + }); + test('id set, play_status missing (post-resolve skeleton) -> active (C1)', () => { + expect(gameSpec.isActive({ steam_appid: '792100' })).toBe(true); + }); + test('both ids set, play_status missing (post-resolve skeleton) -> active (C1)', () => { + expect(gameSpec.isActive({ steam_appid: '792100', rawg_id: '4200' })).toBe(true); + }); + test('canonical enriched note -> static', () => { expect(gameSpec.isActive({ steam_appid: '792100', rawg_id: '4200', play_status: 'Played' })).toBe(false); }); }); From ea5ddd94546f025974cf16f3a1f58942c99ce306 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 22:08:40 +0800 Subject: [PATCH 36/53] 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); + }); +}); From 548e4c8ce0be7e7ed8ab3eace07211004def3a8d Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Mon, 3 Aug 2026 22:09:04 +0800 Subject: [PATCH 37/53] fix(library): isolate per-type sync failures, suppress dry-run notices syncAll() ran each enabled type's sync in sequence with no per-type error handling, so one type throwing (e.g. its vault folder missing) aborted the whole run and skipped libraryLastSync -- every other enabled type silently never synced. Each type's runSync is now wrapped individually: failures are logged (and surfaced via Notice unless quiet) and the loop continues; libraryLastSync still updates after the loop regardless. Also: dry-run sync previews now route spec-level flip notifications (the "chapter/issue out" Notices) through the log instead of a real Notice, since a dry run never actually writes the change it's describing. --- .../obsidian/src/library/LibraryController.ts | 30 +++- tests/library-controller.test.ts | 147 ++++++++++++++++++ 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index fba6a85..21b5a21 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -91,17 +91,25 @@ export class LibraryController { }; } - private makeSpecDeps(): SpecDeps { + /** dryRun=true routes flip notifications to the log instead of a real Notice -- a dry-run + * preview shouldn't pop user-facing notices for changes that were never actually written. */ + private makeSpecDeps(dryRun = false): SpecDeps { return { http: this.makeHttp(), httpText: this.makeHttpText(), getKey: name => this.getKey(name), log: msg => console.log(`[media-db-library] ${msg}`), - notify: msg => this.notify(msg), + notify: msg => { + if (dryRun) { + console.log(`[media-db-library] [dry] ${msg}`); + } else { + this.notify(msg); + } + }, }; } - private makeDeps(spec: MediaTypeSpec): LibraryEngineDeps { + private makeDeps(spec: MediaTypeSpec, dryRun = false): LibraryEngineDeps { const { app } = this.plugin; const folderPath = this.folderFor(spec); const folder = app.vault.getAbstractFileByPath(folderPath); @@ -122,7 +130,7 @@ export class LibraryController { }, sleep: ms => new Promise(r => setTimeout(r, ms)), log: msg => console.log(`[media-db-library] ${msg}`), - specDeps: this.makeSpecDeps(), + specDeps: this.makeSpecDeps(dryRun), }; } @@ -140,7 +148,7 @@ export class LibraryController { private async runSync(spec: MediaTypeSpec, full: boolean, dryRun: boolean, quiet: boolean): Promise { this.warnMissingKey(spec, quiet); - const deps = this.makeDeps(spec); + const deps = this.makeDeps(spec, dryRun); const report = await libraryFolderSync(spec, deps, { full, dryRun }); const mode = dryRun ? 'DRY-RUN ' : ''; if (shouldNotifySync(quiet, report.written, report.errors.length)) { @@ -172,7 +180,7 @@ export class LibraryController { } this.syncing = true; try { - const deps = this.makeDeps(spec); + const deps = this.makeDeps(spec, dryRun); const report = await libraryFolderResolve(spec, deps, { dryRun }); const mode = dryRun ? 'DRY-RUN ' : ''; this.notify( @@ -194,7 +202,15 @@ export class LibraryController { try { for (const spec of this.specs) { if (!this.enabledFor(spec)) continue; - await this.runSync(spec, full, false, quiet); + try { + await this.runSync(spec, full, false, quiet); + } catch (e) { + // one type's failure (e.g. its vault folder is missing) must not abort the + // rest of the run -- log + surface it, then keep going with the other types + const msg = `Library sync failed for ${spec.typeName}: ${e instanceof Error ? e.message : String(e)}`; + console.log(`[media-db-library] ${msg}`); + if (!quiet) this.notify(msg); + } } if (!full) { this.plugin.settings.libraryLastSync = Date.now(); diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index f61697e..4452c1c 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -274,3 +274,150 @@ describe('missing API key handling', () => { expect(notices.filter(m => m.includes('API key not configured')).length).toBe(1); }); }); + +describe('syncAll error isolation (I5)', () => { + test('first spec throws (e.g. missing vault folder) -> remaining specs still synced, lastSync updated', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const calledTypes: string[] = []; + (c as any).makeDeps = (spec: { typeName: string }) => { + calledTypes.push(spec.typeName); + if (spec.typeName === 'manga') { + return { + listNotes: async () => { + throw new Error('Library folder not found: Mangas'); + }, + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + } + return { + listNotes: async () => [], + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + }; + + const before = (c as any).plugin.settings.libraryLastSync; + await c.syncAll(false, false); + + // manga (first in LIBRARY_TYPES) threw, but book/game/comic still got their turn + expect(calledTypes.sort()).toEqual(['book', 'comic', 'game', 'manga']); + expect(notices.some(m => m.includes('Library sync failed for manga'))).toBe(true); + expect((c as any).plugin.settings.libraryLastSync).toBeGreaterThan(before); + }); + + test('quiet sync: per-type failure logged only, no Notice', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => { + logs.push(msg); + }; + (c as any).makeDeps = (spec: { typeName: string }) => ({ + listNotes: async () => { + if (spec.typeName === 'book') throw new Error('boom'); + return []; + }, + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }); + + try { + await c.syncAll(false, true); + } finally { + console.log = origLog; + } + + expect(notices.some(m => m.includes('Library sync failed for book'))).toBe(false); + expect(logs.some(m => m.includes('Library sync failed for book'))).toBe(true); + }); +}); + +describe('dry-run notify suppression (minor)', () => { + test('makeSpecDeps(dryRun=true) -> notify logs instead of raising a Notice', () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => { + logs.push(msg); + }; + try { + const specDeps = (c as any).makeSpecDeps(true); + specDeps.notify('«Test Manga» ch. 5 out'); + } finally { + console.log = origLog; + } + expect(notices).toEqual([]); + expect(logs.some(m => m.includes('«Test Manga» ch. 5 out'))).toBe(true); + }); + + test('makeSpecDeps(dryRun=false) -> notify raises a Notice normally', () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const specDeps = (c as any).makeSpecDeps(false); + specDeps.notify('«Test Manga» ch. 5 out'); + expect(notices).toEqual(['«Test Manga» ch. 5 out']); + }); + + test('dry-run manga flip scenario end-to-end: syncType(dryRun=true) -> no Notice, log entry present', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => { + logs.push(msg); + }; + + const flipSpec = { + typeName: 'manga' as const, + itemType: 'manga_item', + folderSettingKey: 'libraryMangaFolder', + enabledSettingKey: 'libraryMangaEnabled', + throttleMs: 0, + hasId: () => true, + isActive: () => true, + resolve: async () => null, + sync: async (_ctx: unknown, deps: { notify: (msg: string) => void }) => { + deps.notify('«Test Manga» ch. 5 out'); + return { content: 'updated content', flipped: true }; + }, + }; + + (c as any).makeDeps = (spec: unknown, dryRun: boolean) => ({ + listNotes: async () => [{ path: 'Mangas/Test Manga.md' }], + readNote: async () => 'orig content', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: (c as any).makeSpecDeps(dryRun), + }); + + try { + await c.syncType(flipSpec as any, false, true); // dryRun = true + } finally { + console.log = origLog; + } + + // the run-summary Notice (dry-run or not) is unrelated existing behavior and still fires; + // what must NOT happen is the spec's flip-notify reaching a real Notice + expect(notices.some(m => m.includes('«Test Manga» ch. 5 out'))).toBe(false); + expect(logs.some(m => m.includes('«Test Manga» ch. 5 out'))).toBe(true); + }); +}); From 49d7779bc11dda103ddd96e91f0c3ea2d4822e7e Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 11:05:04 +0800 Subject: [PATCH 38/53] feat(library): book author-hint resolve + ambiguous candidate logging --- packages/obsidian/src/library/book.ts | 37 ++++++++++++++--- packages/obsidian/src/library/comic.ts | 13 +++++- packages/obsidian/src/library/game.ts | 12 ++++++ packages/obsidian/src/library/manga.ts | 9 ++++ tests/library-book.test.ts | 57 ++++++++++++++++++++++++++ tests/library-comic.test.ts | 14 +++++++ tests/library-game.test.ts | 13 ++++++ tests/library-manga.test.ts | 13 ++++++ 8 files changed, 161 insertions(+), 7 deletions(-) diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 0ece284..02af346 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -114,6 +114,26 @@ async function fetchDocs(query: string, deps: SpecDeps): Promise { return res?.docs ?? []; } +/** Skeleton/legacy `author` (singular, stock) or canonical `authors` (bracketed list) frontmatter + * field -> first author name, used as a query hint to disambiguate common book titles against + * Open Library's `search.json`. Match rule is unaffected -- still checked against the OL doc's + * `title` field only. */ +function authorHint(fm: Record): string { + const stock = stripQuotes(fm['author']); + const canonical = (fm['authors'] ?? '').trim().replace(/^\[|\]$/g, ''); + const raw = stock || canonical; + if (!raw) return ''; + return stripQuotes(raw.split(',')[0].trim()); +} + +/** Top-candidate identifying info for ambiguous-resolve logging. */ +function candidateSummary(d: any): string { + const olid = String(d.key ?? '').replace(/^\/works\//, ''); + const year = d.first_publish_year != null ? String(d.first_publish_year) : ''; + const author = Array.isArray(d.author_name) ? d.author_name.filter(Boolean).join(', ') : ''; + return `olid=${olid} «${d.title ?? ''}»${year ? ` (${year})` : ''}${author ? ` ${author}` : ''}`; +} + export const bookSpec: MediaTypeSpec = { typeName: 'book', itemType: 'book_item', @@ -135,19 +155,24 @@ export const bookSpec: MediaTypeSpec = { }, async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { - const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); - if (!query) return null; + const title = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); + if (!title) return null; + const hint = authorHint(ctx.frontmatter); + const searchQuery = hint ? `${title} ${hint}` : title; try { - const docs = await fetchDocs(query, deps); + const docs = await fetchDocs(searchQuery, deps); if (docs.length === 0) return null; - const q = query.toLowerCase(); + const q = title.toLowerCase(); const exacts = docs.filter(d => String(d.title ?? '').toLowerCase() === q); const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && docs.length === 1 ? docs[0] : null; - if (!pick) return null; + if (!pick) { + deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`); + return null; + } const olid = String(pick.key ?? '').replace(/^\/works\//, ''); return olid ? { olid } : null; } catch (e) { - deps.log(`book resolve failed for "${query}": ${String(e)}`); + deps.log(`book resolve failed for "${searchQuery}": ${String(e)}`); return null; } }, diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index f9e3933..18fdd2c 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -145,6 +145,13 @@ async function fetchVolumeResults(query: string, key: string, deps: SpecDeps): P return Array.isArray(res?.results) ? res.results : []; } +/** Top-candidate identifying info for ambiguous-resolve logging. */ +function candidateSummary(r: any): string { + const year = r.start_year != null ? String(r.start_year) : ''; + const publisher = r.publisher?.name ?? ''; + return `id=${r.id ?? ''} «${r.name ?? ''}»${year ? ` (${year})` : ''}${publisher ? ` ${publisher}` : ''}`; +} + export const comicSpec: MediaTypeSpec = { typeName: 'comic', itemType: 'comic_item', @@ -180,7 +187,11 @@ export const comicSpec: MediaTypeSpec = { 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; + if (!pick) { + deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map(candidateSummary).join('; ')}`); + return null; + } + return pick.id != null ? { comicvine_id: String(pick.id) } : null; } catch (e) { deps.log(`comic resolve failed for "${query}": ${String(e)}`); return null; diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts index 6c8944f..d26e643 100644 --- a/packages/obsidian/src/library/game.ts +++ b/packages/obsidian/src/library/game.ts @@ -168,6 +168,11 @@ function pickUniqueExact(items: T[], query: string, titleOf: (t: T) => string return null; } +/** Top-candidate identifying info for ambiguous-resolve logging. */ +function candidateSummary(label: string, id: unknown, name: string, year?: string): string { + return `${label}=${id ?? ''} «${name}»${year ? ` (${year})` : ''}`; +} + async function fetchSteamSearch(query: string, deps: SpecDeps): Promise { const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' }); const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {}); @@ -213,6 +218,9 @@ export const gameSpec: MediaTypeSpec = { const items = await fetchSteamSearch(query, deps); const pick = pickUniqueExact(items, query, (it: any) => String(it.name ?? '')); if (pick) return { steam_appid: String(pick.id) }; + if (items.length > 0) { + deps.log(`ambiguous "${query}": candidates: ${items.slice(0, 3).map((it: any) => candidateSummary('appid', it.id, it.name ?? '')).join('; ')}`); + } } catch (e) { deps.log(`game steam storesearch failed for "${query}": ${String(e)}`); } @@ -226,6 +234,10 @@ export const gameSpec: MediaTypeSpec = { const results = await fetchRawgSearch(query, key, deps); const pick = pickUniqueExact(results, query, (it: any) => String(it.name ?? '')); if (pick) return { rawg_id: String(pick.id) }; + if (results.length > 0) { + const year = (it: any) => (typeof it.released === 'string' ? it.released.slice(0, 4) : ''); + deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map((it: any) => candidateSummary('rawg_id', it.id, it.name ?? '', year(it))).join('; ')}`); + } } catch (e) { deps.log(`game rawg search failed for "${query}": ${String(e)}`); } diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index 1ba7fbc..3e29880 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -181,6 +181,12 @@ function resultTitles(r: any): string[] { return titles.filter(Boolean).map((t: string) => String(t).toLowerCase()); } +/** Top-candidate identifying info for ambiguous-resolve logging. */ +function candidateSummary(r: any): string { + const year = r.published?.from ? String(r.published.from).slice(0, 4) : ''; + return `mal_id=${r.mal_id ?? ''} «${r.title ?? ''}»${year ? ` (${year})` : ''}`; +} + 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()}`, {}); @@ -189,6 +195,9 @@ async function resolveMalId(query: string, deps: SpecDeps): Promise resultTitles(r).includes(q)); const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null; + if (!pick) { + deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map(candidateSummary).join('; ')}`); + } return pick ? String(pick.mal_id) : null; } diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index b8d348c..3ab039f 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -325,6 +325,19 @@ describe('bookSpec.resolve', () => { const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); expect(result).toBeNull(); }); + test('ambiguous -> logs top candidates with olid + title', async () => { + const deps = makeDeps({ + http: async () => ({ + docs: [ + { key: '/works/OL1W', title: 'Foo' }, + { key: '/works/OL2W', title: 'Bar' }, + ], + }), + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('olid=OL1W') && m.includes('Foo') && m.includes('olid=OL2W') && m.includes('Bar'))).toBe(true); + }); test('no results -> null', async () => { const deps = makeDeps({ http: async () => ({ docs: [] }) }); const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); @@ -341,3 +354,47 @@ describe('bookSpec.resolve', () => { expect(deps.logCalls.length).toBeGreaterThan(0); }); }); + +describe('bookSpec.resolve — author hint (stock `author` / canonical `authors`)', () => { + test('stock `author` field present -> query is "title author", exact-title match still resolves', async () => { + let capturedUrl = ''; + const deps = makeDeps({ + http: async (url: string) => { + capturedUrl = url; + return olFixture; + }, + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984', author: 'George Orwell' }, ''), deps); + const q = new URL(capturedUrl).searchParams.get('q'); + expect(q).toBe('1984 George Orwell'); + expect(result).toEqual({ olid: 'OL1168083W' }); + }); + + test('canonical `authors` bracketed list -> first author used in query', async () => { + let capturedUrl = ''; + const deps = makeDeps({ + http: async (url: string) => { + capturedUrl = url; + return olFixture; + }, + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984', authors: '[George Orwell, Someone Else]' }, ''), deps); + const q = new URL(capturedUrl).searchParams.get('q'); + expect(q).toBe('1984 George Orwell'); + expect(result).toEqual({ olid: 'OL1168083W' }); + }); + + test('no author anywhere in frontmatter -> query is title only (unchanged behavior)', async () => { + let capturedUrl = ''; + const deps = makeDeps({ + http: async (url: string) => { + capturedUrl = url; + return olFixture; + }, + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + const q = new URL(capturedUrl).searchParams.get('q'); + expect(q).toBe('1984'); + expect(result).toEqual({ olid: 'OL1168083W' }); + }); +}); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index a0e4bd9..7213222 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -290,6 +290,20 @@ describe('comicSpec.resolve', () => { const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); expect(result).toBeNull(); }); + test('ambiguous -> logs top candidates with id + name', 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(); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('id=1') && m.includes('Batman') && m.includes('id=2') && m.includes('Batman Beyond'))).toBe(true); + }); test('no results -> null', async () => { const deps = makeDeps({ http: async () => ({ results: [] }), getKey: () => 'cvkey' }); diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index 8b5d593..32486e8 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -329,6 +329,19 @@ describe('gameSpec.resolve', () => { expect(result).toBeNull(); expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true); }); + test('steam storesearch ambiguous -> logs top candidates with appid + name', async () => { + const deps = makeDeps({ + http: async () => ({ + items: [ + { id: 1, name: 'Foo' }, + { id: 2, name: 'Bar' }, + ], + }), + }); + const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('appid=1') && m.includes('Foo') && m.includes('appid=2') && m.includes('Bar'))).toBe(true); + }); test('steam storesearch throws -> falls through to RAWG', async () => { const deps = makeDeps({ diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index 166161d..5a9694e 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -587,6 +587,19 @@ describe('mangaSpec.resolve', () => { const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); expect(result).toBeNull(); }); + test('ambiguous -> logs top candidates with mal_id + title', 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(); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('mal_id=1') && m.includes('Foo') && m.includes('mal_id=2') && m.includes('Bar'))).toBe(true); + }); test('no results -> null', async () => { const deps = makeDeps({ http: async () => ({ data: [] }) }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); From f146de1b7620b1def2cd8e4a362074a1da6d7217 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 12:44:30 +0800 Subject: [PATCH 39/53] feat: sticky notices for sync/resolve summaries --- .../obsidian/src/library/LibraryController.ts | 10 ++++++---- packages/obsidian/src/main.ts | 16 ++++++++-------- .../src/watchlist/WatchlistController.ts | 2 ++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 21b5a21..1e73d21 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -43,8 +43,8 @@ export class LibraryController { constructor(private plugin: MediaDbPlugin) {} - private notify(msg: string): void { - new Notice(msg); + private notify(msg: string, timeout?: number): void { + new Notice(msg, timeout); } private getKey(name: 'rawg' | 'comicvine'): string { @@ -142,7 +142,7 @@ export class LibraryController { if (quiet) { console.log(`[media-db-library] ${msg}`); } else { - this.notify(msg); + this.notify(msg, 0); } } @@ -155,6 +155,7 @@ export class LibraryController { this.notify( `Library ${mode}sync (${spec.typeName}): ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped` + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + 0, ); } return report; @@ -186,6 +187,7 @@ export class LibraryController { this.notify( `Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match` + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + 0, ); return report; } finally { @@ -209,7 +211,7 @@ export class LibraryController { // rest of the run -- log + surface it, then keep going with the other types const msg = `Library sync failed for ${spec.typeName}: ${e instanceof Error ? e.message : String(e)}`; console.log(`[media-db-library] ${msg}`); - if (!quiet) this.notify(msg); + if (!quiet) this.notify(msg, 0); } } if (!full) { diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index 3c2d6da..4a953aa 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -185,45 +185,45 @@ export default class MediaDbPlugin extends Plugin { this.addCommand({ id: 'watchlist-sync-now', name: 'Watchlist: sync now (airing/active only)', - callback: () => void this.watchlist.syncNow(false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + callback: () => void this.watchlist.syncNow(false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e), 0)), }); this.addCommand({ id: 'watchlist-sync-full', name: 'Watchlist: full sync (all entries)', - callback: () => void this.watchlist.syncNow(true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + callback: () => void this.watchlist.syncNow(true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e), 0)), }); this.addCommand({ id: 'watchlist-sync-dry-run', name: 'Watchlist: dry-run full sync (log only, no writes)', - callback: () => void this.watchlist.syncNow(true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + callback: () => void this.watchlist.syncNow(true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e), 0)), }); this.addCommand({ id: 'watchlist-resolve-ids', name: 'Watchlist: resolve missing TMDB ids', - callback: () => void this.watchlist.resolveMissingIds().catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + callback: () => void this.watchlist.resolveMissingIds().catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e), 0)), }); for (const { spec, slug } of LIBRARY_TYPES) { this.addCommand({ id: `library-sync-${slug}`, name: `Library: sync ${slug} now`, - callback: () => void this.library.syncType(spec, false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + callback: () => void this.library.syncType(spec, false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e), 0)), }); this.addCommand({ id: `library-resolve-${slug}`, name: `Library: resolve ${slug} ids`, - callback: () => void this.library.resolveType(spec).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + callback: () => void this.library.resolveType(spec).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e), 0)), }); this.addCommand({ id: `library-dry-run-${slug}`, name: `Library: dry-run ${slug} full sync`, - callback: () => void this.library.syncType(spec, true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + callback: () => void this.library.syncType(spec, true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e), 0)), }); } this.addCommand({ id: 'library-sync-all', name: 'Library: sync all', - callback: () => void this.library.syncAll(false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + callback: () => void this.library.syncAll(false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e), 0)), }); } diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts index fd38794..d515384 100644 --- a/packages/obsidian/src/watchlist/WatchlistController.ts +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -83,6 +83,7 @@ export class WatchlistController { new Notice( `Watchlist ${mode}sync: ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped to Unwatched` + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + 0, ); } if (!dryRun) { @@ -147,6 +148,7 @@ export class WatchlistController { new Notice( `Watchlist ${mode}resolve: ${report.scanned} scanned, ${report.resolved} resolved, ${report.ambiguous} ambiguous/no match` + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + 0, ); return report; } finally { From d76ed2631b674bc930ff80bb70f21ff28c968741 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 13:13:49 +0800 Subject: [PATCH 40/53] fix(library): surface comic vine api errors + sync summary transparency --- .../obsidian/src/library/LibraryController.ts | 18 ++++++--- packages/obsidian/src/library/comic.ts | 23 ++++++++++- tests/fixtures/comicvine-volume.json | 2 + tests/library-comic.test.ts | 21 ++++++++++ tests/library-controller.test.ts | 40 +++++++++++++++++++ 5 files changed, 98 insertions(+), 6 deletions(-) diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 1e73d21..9162eda 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -146,17 +146,25 @@ export class LibraryController { } } + /** Surfaces every counter, not just synced/written/flipped -- a run where fetches are + * silently failing (e.g. Comic Vine error envelope) must not read as an empty-but-clean + * success just because skippedNoData/errors were left out of the message. */ + private buildSyncSummary(spec: MediaTypeSpec, mode: string, report: LibraryReport): string { + let msg = `Library ${mode}sync (${spec.typeName}): ${report.scanned} scanned, ${report.synced} ok, ${report.written} updated, ${report.flipped.length} flipped`; + if (report.skippedStatic) msg += `, ${report.skippedStatic} static`; + if (report.skippedNoId) msg += `, ${report.skippedNoId} no-id`; + if (report.skippedNoData) msg += `, ${report.skippedNoData} no-data (see console)`; + if (report.errors.length) msg += `, ${report.errors.length} errors (see console)`; + return msg; + } + private async runSync(spec: MediaTypeSpec, full: boolean, dryRun: boolean, quiet: boolean): Promise { this.warnMissingKey(spec, quiet); const deps = this.makeDeps(spec, dryRun); const report = await libraryFolderSync(spec, deps, { full, dryRun }); const mode = dryRun ? 'DRY-RUN ' : ''; if (shouldNotifySync(quiet, report.written, report.errors.length)) { - this.notify( - `Library ${mode}sync (${spec.typeName}): ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped` + - (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), - 0, - ); + this.notify(this.buildSyncSummary(spec, mode, report), 0); } return report; } diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index 18fdd2c..ab27433 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -139,9 +139,27 @@ export function renderComic(r: ComicRecord, myNotes: string, customSections: Cus return fm.join('\n') + '\n' + b.join('\n'); } -async function fetchVolumeResults(query: string, key: string, deps: SpecDeps): Promise { +/** + * Comic Vine returns HTTP 200 even on auth/request failures -- the real signal is the + * envelope's `status_code` (1 = OK; anything else carries a human-readable `error`, e.g. + * "Invalid API Key"). Left un-checked, callers see empty `results` and treat it as + * "nothing found" instead of a real failure. `status_code` absent entirely (as in older + * hand-built test fixtures) is treated as OK -- only an explicit non-1 code trips this. + */ +function checkEnvelope(data: any, deps: SpecDeps): boolean { + if (data && data.status_code !== undefined && data.status_code !== 1) { + const msg = data.error || 'Unknown error'; + deps.log(`Comic Vine error: ${msg}`); + deps.notify(`Comic Vine: ${msg}`); + return false; + } + return true; +} + +async function fetchVolumeResults(query: string, key: string, deps: SpecDeps): Promise { const qs = new URLSearchParams({ api_key: key, format: 'json', filter: `name:${query}`, limit: '10' }); const res = await deps.http(`${COMICVINE_BASE}/volumes/?${qs.toString()}`, {}); + if (!checkEnvelope(res, deps)) return null; return Array.isArray(res?.results) ? res.results : []; } @@ -183,6 +201,7 @@ export const comicSpec: MediaTypeSpec = { if (!query) return null; try { const results = await fetchVolumeResults(query, key, deps); + if (results === null) return null; // envelope error already logged/notified if (results.length === 0) return null; const q = query.toLowerCase(); const exacts = results.filter(r => String(r.name ?? '').toLowerCase() === q); @@ -218,6 +237,8 @@ export const comicSpec: MediaTypeSpec = { return null; } + if (!checkEnvelope(json, deps)) 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 diff --git a/tests/fixtures/comicvine-volume.json b/tests/fixtures/comicvine-volume.json index 949ea88..c2d3d7d 100644 --- a/tests/fixtures/comicvine-volume.json +++ b/tests/fixtures/comicvine-volume.json @@ -1,4 +1,6 @@ { + "error": "OK", + "status_code": 1, "results": { "id": 195824, "name": "Absolute Batman", diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index 7213222..2d6e2aa 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -329,6 +329,16 @@ describe('comicSpec.resolve', () => { const result = await comicSpec.resolve(ctx, deps); expect(result).toBeNull(); }); + + test('Comic Vine error envelope (invalid key) -> null, logged', async () => { + const deps = makeDeps({ + http: async () => ({ error: 'Invalid API Key', status_code: 100, results: [] }), + getKey: () => 'bad-key', + }); + const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.some(m => m.includes('Invalid API Key'))).toBe(true); + }); }); describe('comicSpec.sync — no id / no key', () => { @@ -385,6 +395,17 @@ describe('comicSpec.sync — comicvine enrich', () => { expect(deps.notifyCalls).toEqual([]); }); + test('Comic Vine error envelope (invalid key) -> null, notify + log', async () => { + const deps = makeDeps({ + http: async () => ({ error: 'Invalid API Key', status_code: 100, results: [] }), + getKey: () => 'bad-key', + }); + const result = await comicSpec.sync(ctxFor({ comicvine_id: '195824' }), deps); + expect(result).toBeNull(); + expect(deps.notifyCalls.some(m => m.includes('Invalid API Key'))).toBe(true); + expect(deps.logCalls.some(m => m.includes('Invalid API Key'))).toBe(true); + }); + test('fetches from GET /volume/4050-{id}/ with api_key + format=json', async () => { let calledUrl = ''; const deps = makeDeps({ diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index 4452c1c..7ca14fb 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import type { MediaTypeSpec } from 'packages/obsidian/src/library/types'; import { bookSpec } from 'packages/obsidian/src/library/book'; import { gameSpec } from 'packages/obsidian/src/library/game'; import { LibraryController } from 'packages/obsidian/src/library/LibraryController'; @@ -421,3 +422,42 @@ describe('dry-run notify suppression (minor)', () => { expect(logs.some(m => m.includes('«Test Manga» ch. 5 out'))).toBe(true); }); }); + +describe('sync summary transparency', () => { + test('scanned + no-data counts surface in summary notice, not hidden behind a silent-looking 0/0', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + (c as any).makeDeps = () => ({ + listNotes: async () => [{ path: 'Mangas/Test.md' }], + readNote: async () => 'content', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }); + + // simulates e.g. a Comic Vine error-envelope: fetch "succeeds" (no throw) but spec.sync + // has no usable data -> counted as skippedNoData rather than a written/errors change + const noDataSpec: MediaTypeSpec = { + typeName: 'manga', + itemType: 'manga_item', + folderSettingKey: 'libraryMangaFolder', + enabledSettingKey: 'libraryMangaEnabled', + throttleMs: 0, + hasId: () => true, + isActive: () => true, + resolve: async () => null, + sync: async () => null, + }; + + const report = await c.syncType(noDataSpec, false); + + expect(report.scanned).toBe(1); + expect(report.skippedNoData).toBe(1); + expect(notices.length).toBe(1); + expect(notices[0]).toContain('1 scanned'); + expect(notices[0]).toContain('0 ok'); + expect(notices[0]).toContain('1 no-data (see console)'); + }); +}); From 8a046a0ea228c422d5a737cdccb5b4da1545f3d7 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 14:46:31 +0800 Subject: [PATCH 41/53] feat(library): anilist-primary manga metadata with jikan fallback --- .../obsidian/src/library/LibraryController.ts | 25 +- packages/obsidian/src/library/manga.ts | 247 ++++++++++++- packages/obsidian/src/library/types.ts | 2 + packages/obsidian/src/utils/Utils.ts | 6 + tests/fixtures/anilist-manga-csm.json | 30 ++ tests/fixtures/canonical-manga.md | 1 + tests/library-book.test.ts | 1 + tests/library-comic.test.ts | 1 + tests/library-controller.test.ts | 2 +- tests/library-engine.test.ts | 1 + tests/library-game.test.ts | 1 + tests/library-manga.test.ts | 339 +++++++++++++++++- 12 files changed, 641 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/anilist-manga-csm.json diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 9162eda..9b01b2f 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -4,7 +4,7 @@ import { comicSpec } from 'packages/obsidian/src/library/comic'; import { gameSpec } from 'packages/obsidian/src/library/game'; import { libraryFolderResolve, libraryFolderSync, type LibraryEngineDeps, type LibraryReport, type LibraryResolveReport } from 'packages/obsidian/src/library/LibraryEngine'; import { mangaSpec } from 'packages/obsidian/src/library/manga'; -import type { HttpJsonFn, HttpTextFn, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type { HttpJsonFn, HttpPostJsonFn, HttpTextFn, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; import type MediaDbPlugin from 'packages/obsidian/src/main'; import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; @@ -91,12 +91,35 @@ export class LibraryController { }; } + // POST-JSON counterpart of makeHttp -- needed for GraphQL APIs (AniList) that require a + // POST body instead of query-string params. Same 429/error handling as makeHttp. + private makeHttpPostJson(): HttpPostJsonFn { + return async (url: string, body: unknown, headers: Record): Promise => { + const res = await obsidianFetch( + new Request(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers }, + }), + ); + if (res.status === 429) { + const err = new TmdbRateLimitError(`Library API 429 for ${url}`); + const ra = Number(res.headers.get('retry-after')); + err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000; + throw err; + } + if (res.status !== 200) throw new Error(`Library API ${res.status} for ${url}`); + return await res.json(); + }; + } + /** dryRun=true routes flip notifications to the log instead of a real Notice -- a dry-run * preview shouldn't pop user-facing notices for changes that were never actually written. */ private makeSpecDeps(dryRun = false): SpecDeps { return { http: this.makeHttp(), httpText: this.makeHttpText(), + httpPostJson: this.makeHttpPostJson(), getKey: name => this.getKey(name), log: msg => console.log(`[media-db-library] ${msg}`), notify: msg => { diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index 3e29880..61faddc 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -3,9 +3,61 @@ 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'; +import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; const JIKAN_BASE = 'https://api.jikan.moe/v4'; const MANGADEX_BASE = 'https://api.mangadex.org'; +const ANILIST_BASE = 'https://graphql.anilist.co'; + +// AniList field selection shared by the search + both by-id queries below. `status` comes back +// as an ALL_CAPS enum (mapped via ANILIST_STATUS_MAP); dates come back as {year,month,day} +// objects (mapped via aniListDate); description is HTML (mapped via stripAniListHtml). +const ANILIST_MEDIA_FIELDS = ` + id + idMal + title { romaji english } + status + chapters + volumes + averageScore + genres + staff(perPage: 6) { + edges { role node { name { full } } } + } + startDate { year month day } + endDate { year month day } + description(asHtml: false) + coverImage { large } + siteUrl +`; + +const ANILIST_SEARCH_QUERY = `query ($q: String) { + Page(perPage: 8) { + media(search: $q, type: MANGA) { + ${ANILIST_MEDIA_FIELDS} + } + } +}`; + +const ANILIST_BY_ID_QUERY = `query ($id: Int) { + Media(id: $id, type: MANGA) { + ${ANILIST_MEDIA_FIELDS} + } +}`; + +const ANILIST_BY_IDMAL_QUERY = `query ($id: Int) { + Media(idMal: $id, type: MANGA) { + ${ANILIST_MEDIA_FIELDS} + } +}`; + +const ANILIST_STATUS_MAP: Record = { + RELEASING: 'Publishing', + FINISHED: 'Finished', + HIATUS: 'On Hiatus', + CANCELLED: 'Canceled', + NOT_YET_RELEASED: 'Not Yet Published', +}; export interface MangaRecord { title: string; @@ -25,6 +77,7 @@ export interface MangaRecord { publishedFrom: string | null; publishedTo: string | null; malId: string; + anilistId: string; mangadexId: string; rss: string; poster: string | null; @@ -82,6 +135,7 @@ export function buildManga(jikan: any, prev: Record): MangaRecor 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) : '', + anilistId: stripQuotes(prev['anilist_id']), // Jikan has no AniList concept -- carried through untouched mangadexId, rss, poster: jikan.images?.jpg?.large_image_url ?? null, @@ -90,6 +144,118 @@ export function buildManga(jikan: any, prev: Record): MangaRecor }; } +/** AniList `{year,month,day}` date object -> ISO `YYYY-MM-DD`, or null when any part is missing + * (AniList leaves in-progress end dates as all-null rather than omitting the object). */ +function aniListDate(d: { year?: number | null; month?: number | null; day?: number | null } | null | undefined): string | null { + if (!d || d.year == null || d.month == null || d.day == null) return null; + const mm = String(d.month).padStart(2, '0'); + const dd = String(d.day).padStart(2, '0'); + return `${d.year}-${mm}-${dd}`; +} + +/** AniList `description(asHtml:false)` still carries a handful of literal tags (`
`, ``, + * etc.) and HTML entities -- strip both down to plain text for the synopsis field. */ +function stripAniListHtml(html: string | null | undefined): string { + if (!html) return ''; + return html + .replace(//gi, '\n') + .replace(/<\/?[^>]+(>|$)/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .trim(); +} + +/** + * Pure mapper: AniList `Media` payload (the unwrapped `data.Media` object) + prev frontmatter -> + * canonical MangaRecord. Mirrors `buildManga`'s user-field preservation contract exactly; only + * the upstream field mapping differs. `mal_id` prefers AniList's `idMal` bridge but falls back to + * whatever mal_id the note already carried -- AniList entries without a MAL counterpart (e.g. + * webtoons) must not clobber a manually-set mal_id. + */ +export function buildMangaFromAniList(media: any, prev: Record): MangaRecord { + const romaji: string = media.title?.romaji ?? ''; + const english: string = media.title?.english ?? ''; + const engName = english && english !== romaji ? english : ''; + + const { rating, ratingStars } = deriveRating(prev); + const readStatus = deriveReadStatus(prev); + const lastReadChapter = stripQuotes(prev['last_read_chapter']); + const rss = stripNullSentinel(stripQuotes(prev['rss'])); + const mangadexId = stripQuotes(prev['mangadex_id']); + + const score = typeof media.averageScore === 'number' ? Math.round(media.averageScore) / 10 : null; + + const malId = media.idMal != null ? String(media.idMal) : stripQuotes(prev['mal_id']); + const anilistId = media.id != null ? String(media.id) : stripQuotes(prev['anilist_id']); + + const authors: string[] = ((media.staff?.edges ?? []) as any[]) + .filter(e => typeof e?.role === 'string' && e.role.includes('Story')) + .map(e => e?.node?.name?.full) + .filter(Boolean); + + return { + title: romaji, + engName, + readStatus, + rating, + ratingStars, + lastReadChapter, + latestChapter: parseNumOrNull(prev['latest_chapter']), + lastChapterDate: stripNullSentinel(stripQuotes(prev['last_chapter_date'])), + chapters: media.chapters ?? null, + volumes: media.volumes ?? null, + status: ANILIST_STATUS_MAP[media.status as string] ?? '', + authors, + genre: (media.genres ?? []).filter(Boolean), + score, + publishedFrom: aniListDate(media.startDate), + publishedTo: aniListDate(media.endDate), + malId, + anilistId, + mangadexId, + rss, + poster: media.coverImage?.large ?? null, + // existing url convention = MAL page; only fall back to AniList's own page when the note + // (still) has no mal_id at all -- e.g. AniList-only webtoons with no MAL counterpart + url: malId ? `https://myanimelist.net/manga/${malId}` : (media.siteUrl ?? ''), + synopsis: stripAniListHtml(media.description), + }; +} + +/** `Media(id|idMal: ...)` fetch -- throws (never returns a partial/null record) on GraphQL + * errors or a missing `Media` so callers can uniformly catch-and-fallback to Jikan. */ +async function fetchAniListMedia(deps: SpecDeps, query: string, variables: Record): Promise { + const res = await deps.httpPostJson(ANILIST_BASE, { query, variables }, {}); + if (res?.errors?.length) throw new Error(`AniList error: ${res.errors[0]?.message ?? 'unknown'}`); + const media = res?.data?.Media; + if (!media) throw new Error('AniList: media not found'); + return media; +} + +/** `Page.media` search -- unlike `fetchAniListMedia`, a genuine empty result set is NOT an + * error (it's a legitimate "no matches", same as Jikan's search returning `data: []`); only + * GraphQL/network failures throw. */ +async function fetchAniListSearch(deps: SpecDeps, q: string): Promise { + const res = await deps.httpPostJson(ANILIST_BASE, { query: ANILIST_SEARCH_QUERY, variables: { q } }, {}); + if (res?.errors?.length) throw new Error(`AniList error: ${res.errors[0]?.message ?? 'unknown'}`); + return res?.data?.Page?.media ?? []; +} + +function aniListTitles(m: any): string[] { + const titles = [m?.title?.romaji, m?.title?.english]; + return titles.filter(Boolean).map((t: string) => String(t).toLowerCase()); +} + +/** Top-candidate identifying info for ambiguous-resolve logging (AniList side). */ +function candidateAniListSummary(m: any): string { + const year = m?.startDate?.year ? String(m.startDate.year) : ''; + return `anilist_id=${m?.id ?? ''} «${m?.title?.romaji ?? ''}»${year ? ` (${year})` : ''}`; +} + export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string { const fm = [ '---', @@ -111,6 +277,7 @@ export function renderManga(r: MangaRecord, myNotes: string, customSections: Cus `published_from: ${r.publishedFrom ? r.publishedFrom : 'null'}`, `published_to: ${r.publishedTo ? r.publishedTo : 'null'}`, `mal_id: ${r.malId}`, + `anilist_id: ${r.anilistId}`, `mangadex_id: ${r.mangadexId}`, `rss: ${quotedOrNull(r.rss)}`, `poster: ${quotedOrNull(r.poster)}`, @@ -234,29 +401,66 @@ export const mangaSpec: MediaTypeSpec = { itemType: 'manga_item', folderSettingKey: 'libraryMangaFolder', enabledSettingKey: 'libraryMangaEnabled', - throttleMs: 350, + throttleMs: 700, // AniList's keyless rate limit is 90 req/min hasId(fm: Record): boolean { - return !!stripQuotes(fm['mal_id']); + return !!stripQuotes(fm['mal_id']) || !!stripQuotes(fm['anilist_id']); }, isActive(fm: Record): boolean { const malId = stripQuotes(fm['mal_id']); + const anilistId = stripQuotes(fm['anilist_id']); const status = stripQuotes(fm['status']); - if (!malId || !status) return true; // never enriched -> needs first pass + if ((!malId && !anilistId) || !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 (stripNullSentinel(stripQuotes(fm['rss']))) return true; return false; // Finished + Read/Unread/Dropped -> static }, + // AniList-primary: search AniList first (Page search, unique-exact vs romaji+english). A + // unique hit patches anilist_id (+ mal_id via idMal, when AniList has a MAL bridge for it). + // AniList miss/ambiguous/throw all fall back to the existing Jikan title-search resolve. + // TmdbRateLimitError is never treated as a fallback trigger -- it propagates so the engine's + // withRateLimitRetry wrapper (around the whole resolve() call) retries instead of masking a + // transient 429 as an AniList miss. async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); if (!query) return null; + + let anilistResults: any[] = []; + try { + anilistResults = await fetchAniListSearch(deps, query); + } catch (e) { + if (e instanceof TmdbRateLimitError) throw e; + deps.log(`manga anilist resolve failed for "${query}": ${String(e)}`); + anilistResults = []; + } + + const q = query.toLowerCase(); + const exactAni = anilistResults.filter(m => aniListTitles(m).includes(q)); + const pickAni = exactAni.length === 1 ? exactAni[0] : exactAni.length === 0 && anilistResults.length === 1 ? anilistResults[0] : null; + + if (pickAni) { + const patch: Record = { anilist_id: String(pickAni.id) }; + if (pickAni.idMal != null) patch.mal_id = String(pickAni.idMal); + if (!stripQuotes(ctx.frontmatter['mangadex_id'])) { + const mangadexId = await resolveMangadexId(query, deps); + if (mangadexId) patch.mangadex_id = mangadexId; + } + return patch; + } + + if (anilistResults.length > 1) { + deps.log(`ambiguous "${query}": candidates: ${anilistResults.slice(0, 3).map(candidateAniListSummary).join('; ')}`); + } + + // Jikan fallback (unchanged behavior) let malId: string | null; try { malId = await resolveMalId(query, deps); } catch (e) { + if (e instanceof TmdbRateLimitError) throw e; deps.log(`manga resolve failed for "${query}": ${String(e)}`); return null; } @@ -271,22 +475,41 @@ export const mangaSpec: MediaTypeSpec = { return patch; }, + // AniList-primary: anilist_id present -> fetch by id; else mal_id -> fetch by idMal bridge. + // AniList throwing (network error, GraphQL error, not-found) falls back to the existing Jikan + // full-record fetch when a mal_id is available; with no mal_id there's no fallback path, so + // the sync is skipped (skippedNoData upstream). TmdbRateLimitError propagates uncaught (same + // retry-not-fallback reasoning as resolve() above). 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 + const anilistId = stripQuotes(fm['anilist_id']); + if (!malId && !anilistId) return null; // needs resolve() first - let jikanData: any; + let record: MangaRecord; try { - const res = await deps.http(`${JIKAN_BASE}/manga/${malId}/full`, {}); - jikanData = res?.data; + const media = anilistId + ? await fetchAniListMedia(deps, ANILIST_BY_ID_QUERY, { id: Number(anilistId) }) + : await fetchAniListMedia(deps, ANILIST_BY_IDMAL_QUERY, { id: Number(malId) }); + record = buildMangaFromAniList(media, fm); } catch (e) { - deps.log(`manga jikan fetch failed (mal_id ${malId}): ${String(e)}`); - return null; - } - if (!jikanData) return null; + if (e instanceof TmdbRateLimitError) throw e; + deps.log(`manga anilist fetch failed (${anilistId ? `anilist_id ${anilistId}` : `mal_id ${malId}`}): ${String(e)}`); + if (!malId) return null; // no mal_id -> no Jikan fallback possible + + let jikanData: any; + try { + const res = await deps.http(`${JIKAN_BASE}/manga/${malId}/full`, {}); + jikanData = res?.data; + } catch (e2) { + if (e2 instanceof TmdbRateLimitError) throw e2; + deps.log(`manga jikan fetch failed (mal_id ${malId}): ${String(e2)}`); + return null; + } + if (!jikanData) return null; + record = buildManga(jikanData, fm); + } - const record = buildManga(jikanData, fm); const prevStatus = stripQuotes(fm['status']); const prevLatestChapter = parseNumOrNull(fm['latest_chapter']); const prevLastChapterDate = stripNullSentinel(stripQuotes(fm['last_chapter_date'])); diff --git a/packages/obsidian/src/library/types.ts b/packages/obsidian/src/library/types.ts index e326e2b..0a60417 100644 --- a/packages/obsidian/src/library/types.ts +++ b/packages/obsidian/src/library/types.ts @@ -1,5 +1,6 @@ export type HttpJsonFn = (url: string, headers: Record) => Promise; export type HttpTextFn = (url: string, headers: Record) => Promise; +export type HttpPostJsonFn = (url: string, body: unknown, headers: Record) => Promise; export interface LibraryNoteCtx { frontmatter: Record; @@ -22,6 +23,7 @@ export interface MediaTypeSpec { export interface SpecDeps { http: HttpJsonFn; httpText: HttpTextFn; + httpPostJson: HttpPostJsonFn; getKey(name: 'rawg' | 'comicvine'): string; // '' when unset log(msg: string): void; notify(msg: string): void; diff --git a/packages/obsidian/src/utils/Utils.ts b/packages/obsidian/src/utils/Utils.ts index 5e8a804..9a7818a 100644 --- a/packages/obsidian/src/utils/Utils.ts +++ b/packages/obsidian/src/utils/Utils.ts @@ -286,10 +286,16 @@ export async function obsidianFetch(input: Request): Promise { obs_headers[key] = value; }); + // Request bodies (e.g. POST JSON) must reach requestUrl -- read them off the Request once + // here. GET/HEAD requests have no body, .text() resolves '' for them, so `body` stays + // undefined and existing GET-only call sites are unaffected. + const rawBody = await input.text(); + const res = await requestUrl({ url: input.url, method: input.method, headers: obs_headers, + body: rawBody || undefined, throw: false, // Do not throw on error, handle it manually }); diff --git a/tests/fixtures/anilist-manga-csm.json b/tests/fixtures/anilist-manga-csm.json new file mode 100644 index 0000000..7455d11 --- /dev/null +++ b/tests/fixtures/anilist-manga-csm.json @@ -0,0 +1,30 @@ +{ + "data": { + "Media": { + "id": 105778, + "idMal": 116778, + "title": { + "romaji": "Chainsaw Man", + "english": "Chainsaw Man" + }, + "status": "RELEASING", + "chapters": null, + "volumes": null, + "averageScore": 85, + "genres": ["Action", "Comedy", "Horror", "Supernatural"], + "staff": { + "edges": [ + { "role": "Story & Art", "node": { "name": { "full": "Tatsuki Fujimoto" } } }, + { "role": "Letterer", "node": { "name": { "full": "Some Letterer" } } } + ] + }, + "startDate": { "year": 2018, "month": 12, "day": 3 }, + "endDate": { "year": null, "month": null, "day": null }, + "description": "Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him.
Now he hunts devils for a living.", + "coverImage": { + "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx105778-JCftt5T5vNAY.jpg" + }, + "siteUrl": "https://anilist.co/manga/105778" + } + } +} diff --git a/tests/fixtures/canonical-manga.md b/tests/fixtures/canonical-manga.md index f892d3d..d7a0950 100644 --- a/tests/fixtures/canonical-manga.md +++ b/tests/fixtures/canonical-manga.md @@ -17,6 +17,7 @@ score: 8.7 published_from: 2018-12-03 published_to: null mal_id: 116778 +anilist_id: 105778 mangadex_id: abc-123 rss: "https://example.com/csm-feed.xml" poster: "https://cdn.myanimelist.net/images/manga/3/216464l.jpg" diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index 3ab039f..6c3e543 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -15,6 +15,7 @@ function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: return { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: (msg: string) => { logCalls.push(msg); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index 2d6e2aa..dd01f60 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -15,6 +15,7 @@ function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: return { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: (msg: string) => { logCalls.push(msg); diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index 7ca14fb..e5bfac1 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -77,7 +77,7 @@ describe('maybeCatchUp', () => { }); function fakeSpecDeps() { - return { http: async () => ({}), httpText: async () => '', getKey: () => '', log: () => {}, notify: () => {} }; + return { http: async () => ({}), httpText: async () => '', httpPostJson: async () => ({}), getKey: () => '', log: () => {}, notify: () => {} }; } function deferredDeps(): { deps: LibraryEngineDeps; listNotesCalls: () => number; release: () => void } { diff --git a/tests/library-engine.test.ts b/tests/library-engine.test.ts index a1fcd24..d089fe6 100644 --- a/tests/library-engine.test.ts +++ b/tests/library-engine.test.ts @@ -75,6 +75,7 @@ function makeDeps(notes: { path: string; content: string }[], specDeps: Partial< specDeps: { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: () => {}, notify: () => {}, diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index 32486e8..a37c60b 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -15,6 +15,7 @@ function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: return { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: (msg: string) => { logCalls.push(msg); diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index 5a9694e..e464f58 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -1,12 +1,15 @@ 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 { buildManga, buildMangaFromAniList, renderManga, mangaSpec, type MangaRecord } from 'packages/obsidian/src/library/manga'; import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; +import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; import jikanFixture from 'tests/fixtures/jikan-manga-csm.json'; import mangadexFixture from 'tests/fixtures/mangadex-feed.json'; +import anilistFixture from 'tests/fixtures/anilist-manga-csm.json'; const JIKAN_DATA = jikanFixture.data; +const ANILIST_MEDIA = anilistFixture.data.Media; const EMPTY_PREV: Record = {}; @@ -16,6 +19,7 @@ function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: return { http: async () => ({}), httpText: async () => '', + httpPostJson: async () => ({}), getKey: () => '', log: (msg: string) => { logCalls.push(msg); @@ -164,6 +168,7 @@ describe('renderManga golden', () => { publishedFrom: '2018-12-03', publishedTo: null, malId: '116778', + anilistId: '105778', mangadexId: 'abc-123', rss: 'https://example.com/csm-feed.xml', poster: 'https://cdn.myanimelist.net/images/manga/3/216464l.jpg', @@ -684,3 +689,335 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => { expect(mangadexCalled).toBe(false); }); }); + +describe('buildMangaFromAniList field mapping', () => { + const r = buildMangaFromAniList(ANILIST_MEDIA, EMPTY_PREV); + + test('core fields', () => { + expect(r.title).toBe('Chainsaw Man'); + expect(r.anilistId).toBe('105778'); + expect(r.malId).toBe('116778'); // from idMal bridge + expect(r.status).toBe('Publishing'); // RELEASING -> Publishing + expect(r.chapters).toBeNull(); + expect(r.volumes).toBeNull(); + expect(r.genre).toEqual(['Action', 'Comedy', 'Horror', 'Supernatural']); + expect(r.poster).toBe('https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/bx105778-JCftt5T5vNAY.jpg'); + expect(r.url).toBe('https://myanimelist.net/manga/116778'); // MAL convention, mal_id present + }); + + test('authors: only staff roles containing "Story" -> excludes Letterer', () => { + expect(r.authors).toEqual(['Tatsuki Fujimoto']); + }); + + test('score: averageScore 85 -> 8.5', () => { + expect(r.score).toBe(8.5); + }); + test('averageScore 73 -> 7.3', () => { + expect(buildMangaFromAniList({ ...ANILIST_MEDIA, averageScore: 73 }, EMPTY_PREV).score).toBe(7.3); + }); + test('non-number averageScore -> null', () => { + expect(buildMangaFromAniList({ ...ANILIST_MEDIA, averageScore: null }, EMPTY_PREV).score).toBeNull(); + }); + + test('dates: startDate full -> ISO padded', () => { + expect(r.publishedFrom).toBe('2018-12-03'); + }); + test('dates: endDate all-null parts -> null', () => { + expect(r.publishedTo).toBeNull(); + }); + test('dates: partial date (missing day) -> null, not a malformed ISO string', () => { + const media = { ...ANILIST_MEDIA, startDate: { year: 2018, month: 12, day: null } }; + expect(buildMangaFromAniList(media, EMPTY_PREV).publishedFrom).toBeNull(); + }); + + test('description:
-> newline, stripped, trimmed', () => { + expect(r.synopsis).toBe('Denji has been robbed of a normal life ever since his Chainsaw Devil, Pochita, merged with him.\nNow he hunts devils for a living.'); + }); + + test('eng_name empty when title.english === title.romaji', () => { + expect(r.engName).toBe(''); + }); + test('eng_name set when title.english differs from title.romaji', () => { + const media = { ...ANILIST_MEDIA, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man EN Alt' } }; + expect(buildMangaFromAniList(media, EMPTY_PREV).engName).toBe('Chainsaw Man EN Alt'); + }); + + test('status map: exhaustive', () => { + const map: Record = { + RELEASING: 'Publishing', + FINISHED: 'Finished', + HIATUS: 'On Hiatus', + CANCELLED: 'Canceled', + NOT_YET_RELEASED: 'Not Yet Published', + }; + for (const [anilist, expected] of Object.entries(map)) { + expect(buildMangaFromAniList({ ...ANILIST_MEDIA, status: anilist }, EMPTY_PREV).status).toBe(expected); + } + }); + + test('idMal null -> mal_id falls back to prev (preserves existing mal_id, does not clobber)', () => { + const media = { ...ANILIST_MEDIA, idMal: null }; + const result = buildMangaFromAniList(media, { mal_id: '999' }); + expect(result.malId).toBe('999'); + expect(result.url).toBe('https://myanimelist.net/manga/999'); + }); + test('idMal null + no prev mal_id -> mal_id empty, url falls back to AniList siteUrl', () => { + const media = { ...ANILIST_MEDIA, idMal: null }; + const result = buildMangaFromAniList(media, EMPTY_PREV); + expect(result.malId).toBe(''); + expect(result.url).toBe('https://anilist.co/manga/105778'); + }); + + test('user-field preservation: read_status/rating/last_read_chapter/rss/mangadex_id carried from prev (shared with buildManga)', () => { + const prev = { + read_status: 'Reading', + rating: '4', + rating_stars: '⭐️⭐️⭐️⭐️', + last_read_chapter: '150', + rss: 'https://x.y/feed.xml', + mangadex_id: 'abc-123', + latest_chapter: '213', + last_chapter_date: '2026-07-16', + }; + const result = buildMangaFromAniList(ANILIST_MEDIA, prev); + expect(result.readStatus).toBe('Reading'); + expect(result.rating).toBe('4'); + expect(result.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + expect(result.lastReadChapter).toBe('150'); + expect(result.rss).toBe('https://x.y/feed.xml'); + expect(result.mangadexId).toBe('abc-123'); + expect(result.latestChapter).toBe(213); + expect(result.lastChapterDate).toBe('2026-07-16'); + }); +}); + +describe('mangaSpec.hasId / isActive — anilist_id counts as an id too', () => { + test('hasId: anilist_id set, no mal_id -> true', () => { + expect(mangaSpec.hasId({ anilist_id: '105778' })).toBe(true); + }); + test('isActive: never enriched (neither id) -> active', () => { + expect(mangaSpec.isActive({})).toBe(true); + }); + test('isActive: anilist_id only, Publishing -> active', () => { + expect(mangaSpec.isActive({ anilist_id: '1', status: 'Publishing', read_status: 'Unread' })).toBe(true); + }); + test('isActive: anilist_id only, Finished + Read -> static', () => { + expect(mangaSpec.isActive({ anilist_id: '1', status: 'Finished', read_status: 'Read' })).toBe(false); + }); +}); + +describe('mangaSpec.resolve — AniList primary', () => { + function anilistPage(media: any[]) { + return { data: { Page: { media } } }; + } + + test('unique exact romaji match -> patches anilist_id + mal_id (idMal bridge)', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), // jikan/mangadex: no exact match -> mangadex_id left unset + httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); + }); + + test('unique exact match via english title only (case-insensitive) -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), + httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'チェンソーマン', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); + }); + + test('no exact match, sole AniList result -> accepted (unique-exact fallback rule)', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), + httpPostJson: async () => anilistPage([{ id: 999, idMal: 888, title: { romaji: 'Some Other Title', english: '' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '888', anilist_id: '999' }); + }); + + test('AniList hit, idMal null (no MAL bridge) -> patch has anilist_id only', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), + httpPostJson: async () => anilistPage([{ id: 105778, idMal: null, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ anilist_id: '105778' }); + }); + + test('AniList ambiguous (multiple, no exact) -> falls back to Jikan search, logs anilist candidates', async () => { + const deps = makeDeps({ + http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }), + httpPostJson: async () => + anilistPage([ + { id: 1, idMal: 1, title: { romaji: 'Foo', english: '' }, startDate: { year: 2020 } }, + { id: 2, idMal: 2, title: { romaji: 'Bar', english: '' }, startDate: { year: 2021 } }, + ]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('anilist_id=1') && m.includes('Foo') && m.includes('anilist_id=2') && m.includes('Bar'))).toBe(true); + }); + + test('AniList miss (empty results) -> falls straight to Jikan, no ambiguous log from AniList side', async () => { + const deps = makeDeps({ + http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }), + httpPostJson: async () => anilistPage([]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + expect(deps.logCalls.some(m => m.includes('anilist_id='))).toBe(false); + }); + + test('AniList search throws generic error -> log, falls back to Jikan (no overall failure)', async () => { + const deps = makeDeps({ + http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }), + httpPostJson: async () => { + throw new Error('anilist down'); + }, + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778' }); + expect(deps.logCalls.some(m => m.toLowerCase().includes('anilist'))).toBe(true); + }); + + test('AniList search throws TmdbRateLimitError -> propagates, no Jikan fallback attempted', async () => { + let jikanCalled = false; + const deps = makeDeps({ + http: async () => { + jikanCalled = true; + return { data: [] }; + }, + httpPostJson: async () => { + throw new TmdbRateLimitError('429'); + }, + }); + await expect(mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps)).rejects.toBeInstanceOf(TmdbRateLimitError); + expect(jikanCalled).toBe(false); + }); + + test('AniList hit, no mangadex_id in fm -> mangadex resolve still attempted (best-effort, unchanged)', async () => { + const deps = makeDeps({ + http: async (url: string) => (url.includes('mangadex.org') ? { data: [{ id: 'a1b2c3d4-uuid', attributes: { title: { en: 'Chainsaw Man' } } }] } : { data: [] }), + httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', anilist_id: '105778', mangadex_id: 'a1b2c3d4-uuid' }); + }); + + test('mangadex_id already present -> mangadex search skipped, even on an AniList hit', async () => { + let mangadexCalled = false; + const deps = makeDeps({ + http: async (url: string) => { + if (url.includes('mangadex.org')) mangadexCalled = true; + return { data: [] }; + }, + httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man', mangadex_id: 'existing-uuid' }, ''), deps); + expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); + expect(mangadexCalled).toBe(false); + }); +}); + +describe('mangaSpec.sync — AniList primary enrich', () => { + test('anilist_id present -> fetches Media(id:...), builds AniList record', async () => { + let captured: any = null; + const deps = makeDeps({ + httpPostJson: async (url: string, body: unknown) => { + captured = body; + return anilistFixture; + }, + }); + const fm = { anilist_id: '105778', read_status: 'Unread' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect(result!.content).toContain('title: Chainsaw Man'); + expect(result!.content).toContain('anilist_id: 105778'); + expect(result!.content).toContain('mal_id: 116778'); + expect(result!.content).toContain('status: Publishing'); + expect(result!.content).toContain('score: 8.5'); + expect((captured as any).query).toContain('Media'); + expect((captured as any).query).toContain('MANGA'); + expect((captured as any).variables).toEqual({ id: 105778 }); + }); + + test('mal_id present, no anilist_id -> fetches Media(idMal:...), backfills anilist_id', async () => { + let captured: any = null; + const deps = makeDeps({ + httpPostJson: async (url: string, body: unknown) => { + captured = body; + return anilistFixture; + }, + }); + const fm = { mal_id: '116778', read_status: 'Unread' }; + const result = await mangaSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect((captured as any).query).toContain('idMal'); + expect((captured as any).variables).toEqual({ id: 116778 }); + expect(result!.content).toContain('anilist_id: 105778'); + }); + + test('AniList throw (generic error), mal_id present -> Jikan fallback, prior anilist_id preserved verbatim', async () => { + const deps = makeDeps({ + httpPostJson: async () => { + throw new Error('anilist down'); + }, + http: async () => jikanFixture, + }); + const fm = { anilist_id: '999999', 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!.content).toContain('anilist_id: 999999'); + expect(result!.content).toContain('mal_id: 116778'); + expect(result!.content).toContain('latest_chapter: 213'); + expect(deps.logCalls.some(m => m.toLowerCase().includes('anilist'))).toBe(true); + }); + + test('AniList throw, no mal_id available -> no Jikan fallback possible, returns null', async () => { + let jikanCalled = false; + const deps = makeDeps({ + httpPostJson: async () => { + throw new Error('anilist deleted entry'); + }, + http: async () => { + jikanCalled = true; + return jikanFixture; + }, + }); + const result = await mangaSpec.sync(ctxFor({ anilist_id: '105778' }), deps); + expect(result).toBeNull(); + expect(jikanCalled).toBe(false); + expect(deps.logCalls.some(m => m.toLowerCase().includes('anilist'))).toBe(true); + }); + + test('AniList throws TmdbRateLimitError -> propagates uncaught, no Jikan fallback attempted', async () => { + let jikanCalled = false; + const deps = makeDeps({ + httpPostJson: async () => { + throw new TmdbRateLimitError('429'); + }, + http: async () => { + jikanCalled = true; + return jikanFixture; + }, + }); + await expect(mangaSpec.sync(ctxFor({ mal_id: '116778' }), deps)).rejects.toBeInstanceOf(TmdbRateLimitError); + expect(jikanCalled).toBe(false); + }); + + test('chapter cascade (rss) still works on top of an AniList-built record', async () => { + const deps = makeDeps({ + httpPostJson: async () => anilistFixture, + httpText: async () => RSS_214, + }); + const fm = { anilist_id: '105778', 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(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']); + }); +}); From 3eb89772c73a6e563b965cdab89f0970886d7f2d Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 15:00:16 +0800 Subject: [PATCH 42/53] fix(library): exclude light novels from anilist manga resolve --- packages/obsidian/src/library/manga.ts | 2 +- tests/library-manga.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index 61faddc..3f1908b 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -33,7 +33,7 @@ const ANILIST_MEDIA_FIELDS = ` const ANILIST_SEARCH_QUERY = `query ($q: String) { Page(perPage: 8) { - media(search: $q, type: MANGA) { + media(search: $q, type: MANGA, format_not_in: [NOVEL]) { ${ANILIST_MEDIA_FIELDS} } } diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index e464f58..c0de073 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -920,6 +920,19 @@ describe('mangaSpec.resolve — AniList primary', () => { expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); expect(mangadexCalled).toBe(false); }); + + test('search query includes format_not_in: [NOVEL] to exclude light novels', async () => { + let capturedQuery = ''; + const deps = makeDeps({ + http: async () => ({ data: [] }), + httpPostJson: async (url: string, body: any) => { + capturedQuery = body.query; + return anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Overlord', english: 'Overlord' } }]); + }, + }); + await mangaSpec.resolve(ctxFor({ title: 'Overlord' }, ''), deps); + expect(capturedQuery).toContain('format_not_in: [NOVEL]'); + }); }); describe('mangaSpec.sync — AniList primary enrich', () => { From d7162d3ac20ce36a3bf4ca0fb8fb456e8adb07ee Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 15:24:50 +0800 Subject: [PATCH 43/53] feat(library): interactive candidate picker for ambiguous resolves --- .../src/library/CandidatePickerModal.ts | 60 ++++++++++ .../obsidian/src/library/LibraryController.ts | 39 ++++-- .../obsidian/src/library/LibraryEngine.ts | 25 +++- packages/obsidian/src/library/book.ts | 20 +++- packages/obsidian/src/library/comic.ts | 20 +++- packages/obsidian/src/library/game.ts | 29 +++-- packages/obsidian/src/library/manga.ts | 29 ++++- packages/obsidian/src/library/types.ts | 11 +- tests/library-book.test.ts | 21 ++-- tests/library-candidate-picker-modal.test.ts | 57 +++++++++ tests/library-comic.test.ts | 15 ++- tests/library-controller.test.ts | 111 ++++++++++++++++++ tests/library-engine.test.ts | 61 +++++++++- tests/library-game.test.ts | 21 ++-- tests/library-manga.test.ts | 50 +++++--- tests/setup.ts | 34 ++++-- 16 files changed, 516 insertions(+), 87 deletions(-) create mode 100644 packages/obsidian/src/library/CandidatePickerModal.ts create mode 100644 tests/library-candidate-picker-modal.test.ts diff --git a/packages/obsidian/src/library/CandidatePickerModal.ts b/packages/obsidian/src/library/CandidatePickerModal.ts new file mode 100644 index 0000000..0fb5f6f --- /dev/null +++ b/packages/obsidian/src/library/CandidatePickerModal.ts @@ -0,0 +1,60 @@ +import type { App } from 'obsidian'; +import { FuzzySuggestModal } from 'obsidian'; +import type { ResolveCandidate } from 'packages/obsidian/src/library/types'; + +const SKIP_LABEL = 'Skip'; + +interface PickerItem { + label: string; + index: number | null; // null = Skip +} + +/** + * Fuzzy picker for an ambiguous resolve() result. `pick()` resolves to the chosen candidate's + * index, or null when the user picks "Skip" / dismisses the modal (Esc, click-outside) without + * choosing -- both are treated identically by the caller (LibraryController.resolveType). + */ +export class CandidatePickerModal extends FuzzySuggestModal { + private settled = false; + private resolveFn?: (index: number | null) => void; + + constructor( + app: App, + private filename: string, + private candidates: ResolveCandidate[], + ) { + super(app); + this.setTitle(filename); + this.setPlaceholder(`Pick a match for ${filename}`); + } + + getItems(): PickerItem[] { + return [...this.candidates.map((c, index) => ({ label: c.label, index })), { label: SKIP_LABEL, index: null }]; + } + + getItemText(item: PickerItem): string { + return item.label; + } + + onChooseItem(item: PickerItem, _evt: MouseEvent | KeyboardEvent): void { + this.settle(item.index); + } + + onClose(): void { + this.settle(null); // dismissed without choosing -> treat as skip + this.contentEl.empty(); + } + + private settle(index: number | null): void { + if (this.settled) return; + this.settled = true; + this.resolveFn?.(index); + } + + pick(): Promise { + return new Promise(resolve => { + this.resolveFn = resolve; + this.open(); + }); + } +} diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 9b01b2f..528d9c2 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -1,12 +1,14 @@ import { Notice, TFile, TFolder } from 'obsidian'; import { bookSpec } from 'packages/obsidian/src/library/book'; +import { CandidatePickerModal } from 'packages/obsidian/src/library/CandidatePickerModal'; import { comicSpec } from 'packages/obsidian/src/library/comic'; import { gameSpec } from 'packages/obsidian/src/library/game'; import { libraryFolderResolve, libraryFolderSync, type LibraryEngineDeps, type LibraryReport, type LibraryResolveReport } from 'packages/obsidian/src/library/LibraryEngine'; import { mangaSpec } from 'packages/obsidian/src/library/manga'; -import type { HttpJsonFn, HttpPostJsonFn, HttpTextFn, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type { HttpJsonFn, HttpPostJsonFn, HttpTextFn, MediaTypeSpec, ResolveCandidate, SpecDeps } from 'packages/obsidian/src/library/types'; import type MediaDbPlugin from 'packages/obsidian/src/main'; import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; +import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter'; import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; import { shouldNotifySync } from 'packages/obsidian/src/watchlist/WatchlistController'; @@ -34,7 +36,7 @@ function emptyReport(): LibraryReport { } function emptyResolveReport(): LibraryResolveReport { - return { resolved: [], ambiguous: [], errors: [] }; + return { resolved: [], ambiguous: [], needsChoice: [], skippedNoResolve: 0, errors: [] }; } export class LibraryController { @@ -205,6 +207,12 @@ export class LibraryController { } } + /** Opens the real candidate picker modal; tests override this method directly to avoid needing a real Obsidian modal. */ + private async pickCandidate(filename: string, candidates: ResolveCandidate[]): Promise { + const modal = new CandidatePickerModal(this.plugin.app, filename, candidates); + return await modal.pick(); + } + async resolveType(spec: MediaTypeSpec, dryRun = false): Promise { if (this.syncing) { this.notify('Library sync already running'); @@ -214,12 +222,29 @@ export class LibraryController { try { const deps = this.makeDeps(spec, dryRun); const report = await libraryFolderResolve(spec, deps, { dryRun }); + + let picked = 0; + let skipped = 0; + if (!dryRun) { + for (const entry of report.needsChoice) { + const idx = await this.pickCandidate(entry.filename, entry.candidates); + const candidate = idx != null ? entry.candidates[idx] : undefined; + if (!candidate) { + skipped++; + continue; + } + const content = await deps.readNote(entry.path); + await deps.writeNote(entry.path, patchFrontmatter(content, candidate.patches, { defaultType: spec.itemType })); + report.resolved.push(entry.path); + picked++; + } + } + const mode = dryRun ? 'DRY-RUN ' : ''; - this.notify( - `Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match` + - (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), - 0, - ); + let msg = `Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match`; + if (report.needsChoice.length) msg += `, ${picked} picked, ${skipped} skipped`; + if (report.errors.length) msg += `, ${report.errors.length} errors (see console)`; + this.notify(msg, 0); return report; } finally { this.syncing = false; diff --git a/packages/obsidian/src/library/LibraryEngine.ts b/packages/obsidian/src/library/LibraryEngine.ts index 6fe135d..8488d8e 100644 --- a/packages/obsidian/src/library/LibraryEngine.ts +++ b/packages/obsidian/src/library/LibraryEngine.ts @@ -1,4 +1,4 @@ -import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type { LibraryNoteCtx, MediaTypeSpec, ResolveCandidate, SpecDeps } from 'packages/obsidian/src/library/types'; import { parseNote, stripQuotes } from 'packages/obsidian/src/watchlist/parse'; import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter'; import { withRateLimitRetry } from 'packages/obsidian/src/watchlist/SyncEngine'; @@ -26,6 +26,8 @@ export interface LibraryReport { export interface LibraryResolveReport { resolved: string[]; ambiguous: string[]; + needsChoice: { path: string; filename: string; candidates: ResolveCandidate[] }[]; + skippedNoResolve: number; errors: { path: string; error: string }[]; } @@ -89,8 +91,13 @@ export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngine return report; } +/** `no_resolve: true` opts a note out of the entire resolve pass (all specs), same as a manual skip. */ +function hasNoResolveFlag(fm: Record): boolean { + return stripQuotes(fm['no_resolve']) === 'true'; +} + export async function libraryFolderResolve(spec: MediaTypeSpec, deps: LibraryEngineDeps, opts: { dryRun?: boolean } = {}): Promise { - const report: LibraryResolveReport = { resolved: [], ambiguous: [], errors: [] }; + const report: LibraryResolveReport = { resolved: [], ambiguous: [], needsChoice: [], skippedNoResolve: 0, errors: [] }; const notes = await deps.listNotes(); for (const note of notes) { try { @@ -98,16 +105,24 @@ export async function libraryFolderResolve(spec: MediaTypeSpec, deps: LibraryEng const { frontmatter, body } = parseNote(content); const filename = filenameOf(note.path); if (isSkippableNote(filename, frontmatter, spec.itemType)) continue; + if (hasNoResolveFlag(frontmatter)) { + report.skippedNoResolve++; + deps.log(`no_resolve flag set, skipping: ${note.path}`); + continue; + } if (spec.hasId(frontmatter)) continue; // already resolved const ctx: LibraryNoteCtx = { frontmatter, body, filename }; - const patch = await withRateLimitRetry(() => spec.resolve(ctx, deps.specDeps), deps.sleep); - if (!patch) { + const outcome = await withRateLimitRetry(() => spec.resolve(ctx, deps.specDeps), deps.sleep); + if (!outcome) { report.ambiguous.push(note.path); deps.log(`ambiguous/no match: ${note.path}`); + } else if ('candidates' in outcome) { + report.needsChoice.push({ path: note.path, filename, candidates: outcome.candidates }); + deps.log(`needs choice (${outcome.candidates.length} candidates): ${note.path}`); } else { report.resolved.push(note.path); deps.log(`${opts.dryRun ? '[dry] ' : ''}resolved ${note.path}`); - if (!opts.dryRun) await deps.writeNote(note.path, patchFrontmatter(content, patch, { defaultType: spec.itemType })); + if (!opts.dryRun) await deps.writeNote(note.path, patchFrontmatter(content, outcome.patches, { defaultType: spec.itemType })); } await deps.sleep(spec.throttleMs); } catch (e) { diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 02af346..72624a7 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -1,4 +1,4 @@ -import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type { LibraryNoteCtx, MediaTypeSpec, ResolveOutcome, 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'; @@ -134,6 +134,14 @@ function candidateSummary(d: any): string { return `olid=${olid} «${d.title ?? ''}»${year ? ` (${year})` : ''}${author ? ` ${author}` : ''}`; } +/** Human-readable picker label -- same title/year/author info as candidateSummary, minus the olid prefix. */ +function candidateLabel(d: any): string { + const year = d.first_publish_year != null ? String(d.first_publish_year) : ''; + const author = Array.isArray(d.author_name) ? d.author_name.filter(Boolean).join(', ') : ''; + const meta = [author, year].filter(Boolean).join(', '); + return meta ? `${d.title ?? ''} (${meta})` : (d.title ?? ''); +} + export const bookSpec: MediaTypeSpec = { typeName: 'book', itemType: 'book_item', @@ -154,7 +162,7 @@ export const bookSpec: MediaTypeSpec = { return !stripQuotes(fm['olid']) || !stripQuotes(fm['read_status']); }, - async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { + async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise { const title = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); if (!title) return null; const hint = authorHint(ctx.frontmatter); @@ -167,10 +175,14 @@ export const bookSpec: MediaTypeSpec = { const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && docs.length === 1 ? docs[0] : null; if (!pick) { deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`); - return null; + const candidates = docs + .slice(0, 6) + .map(d => ({ label: candidateLabel(d), patches: { olid: String(d.key ?? '').replace(/^\/works\//, '') } })) + .filter(c => c.patches.olid); + return candidates.length ? { candidates } : null; } const olid = String(pick.key ?? '').replace(/^\/works\//, ''); - return olid ? { olid } : null; + return olid ? { patches: { olid } } : null; } catch (e) { deps.log(`book resolve failed for "${searchQuery}": ${String(e)}`); return null; diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index ab27433..f279d98 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -1,4 +1,4 @@ -import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type { LibraryNoteCtx, MediaTypeSpec, ResolveOutcome, 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'; @@ -170,6 +170,14 @@ function candidateSummary(r: any): string { return `id=${r.id ?? ''} «${r.name ?? ''}»${year ? ` (${year})` : ''}${publisher ? ` ${publisher}` : ''}`; } +/** Human-readable picker label -- same name/year/publisher info as candidateSummary, minus the id prefix. */ +function candidateLabel(r: any): string { + const year = r.start_year != null ? String(r.start_year) : ''; + const publisher = r.publisher?.name ?? ''; + const meta = [publisher, year].filter(Boolean).join(', '); + return meta ? `${r.name ?? ''} (${meta})` : (r.name ?? ''); +} + export const comicSpec: MediaTypeSpec = { typeName: 'comic', itemType: 'comic_item', @@ -191,7 +199,7 @@ export const comicSpec: MediaTypeSpec = { return false; }, - async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { + async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise { const key = deps.getKey('comicvine'); if (!key) { deps.log('comic resolve: no Comic Vine key configured, skipping id lookup'); @@ -208,9 +216,13 @@ export const comicSpec: MediaTypeSpec = { const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null; if (!pick) { deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map(candidateSummary).join('; ')}`); - return null; + const candidates = results + .slice(0, 6) + .filter(r => r.id != null) + .map(r => ({ label: candidateLabel(r), patches: { comicvine_id: String(r.id) } })); + return candidates.length ? { candidates } : null; } - return pick.id != null ? { comicvine_id: String(pick.id) } : null; + return pick.id != null ? { patches: { comicvine_id: String(pick.id) } } : null; } catch (e) { deps.log(`comic resolve failed for "${query}": ${String(e)}`); return null; diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts index d26e643..365d54f 100644 --- a/packages/obsidian/src/library/game.ts +++ b/packages/obsidian/src/library/game.ts @@ -1,4 +1,4 @@ -import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type { LibraryNoteCtx, MediaTypeSpec, ResolveCandidate, ResolveOutcome, 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'; @@ -173,6 +173,12 @@ function candidateSummary(label: string, id: unknown, name: string, year?: strin return `${label}=${id ?? ''} «${name}»${year ? ` (${year})` : ''}`; } +/** Human-readable picker label for a Steam storesearch item -- Steam's search payload carries no + * year/date, so name is all there is to show (matches what candidateSummary already logs for it). */ +function steamCandidateLabel(it: any): string { + return String(it?.name ?? ''); +} + async function fetchSteamSearch(query: string, deps: SpecDeps): Promise { const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' }); const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {}); @@ -205,21 +211,30 @@ export const gameSpec: MediaTypeSpec = { return (!stripQuotes(fm['steam_appid']) && !stripQuotes(fm['rawg_id'])) || !stripQuotes(fm['play_status']); }, - async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { + // Steam-primary: RAWG is still tried as a fallback on a Steam ambiguous/miss (unchanged). Only + // when NEITHER source lands a unique match do Steam's ambiguous results (if any) come back as + // candidates for the user to pick from, instead of a bare null -- RAWG-only ambiguity (Steam + // had 0/1 results) still returns null unchanged. + async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise { // fastest, no-network path: 28/31 real game notes already carry a Steam store url const url = stripQuotes(ctx.frontmatter['url']); const urlMatch = /store\.steampowered\.com\/app\/(\d+)/.exec(url); - if (urlMatch) return { steam_appid: urlMatch[1] }; + if (urlMatch) return { patches: { steam_appid: urlMatch[1] } }; const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); if (!query) return null; + let steamCandidates: ResolveCandidate[] = []; try { const items = await fetchSteamSearch(query, deps); const pick = pickUniqueExact(items, query, (it: any) => String(it.name ?? '')); - if (pick) return { steam_appid: String(pick.id) }; + if (pick) return { patches: { steam_appid: String(pick.id) } }; if (items.length > 0) { deps.log(`ambiguous "${query}": candidates: ${items.slice(0, 3).map((it: any) => candidateSummary('appid', it.id, it.name ?? '')).join('; ')}`); + steamCandidates = items + .slice(0, 6) + .filter((it: any) => it.id != null) + .map((it: any) => ({ label: steamCandidateLabel(it), patches: { steam_appid: String(it.id) } })); } } catch (e) { deps.log(`game steam storesearch failed for "${query}": ${String(e)}`); @@ -228,12 +243,12 @@ export const gameSpec: MediaTypeSpec = { const key = deps.getKey('rawg'); if (!key) { deps.log(`game resolve: no RAWG key configured, skipping RAWG fallback for "${query}"`); - return null; + return steamCandidates.length ? { candidates: steamCandidates } : null; } try { const results = await fetchRawgSearch(query, key, deps); const pick = pickUniqueExact(results, query, (it: any) => String(it.name ?? '')); - if (pick) return { rawg_id: String(pick.id) }; + if (pick) return { patches: { rawg_id: String(pick.id) } }; if (results.length > 0) { const year = (it: any) => (typeof it.released === 'string' ? it.released.slice(0, 4) : ''); deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map((it: any) => candidateSummary('rawg_id', it.id, it.name ?? '', year(it))).join('; ')}`); @@ -241,7 +256,7 @@ export const gameSpec: MediaTypeSpec = { } catch (e) { deps.log(`game rawg search failed for "${query}": ${String(e)}`); } - return null; + return steamCandidates.length ? { candidates: steamCandidates } : null; }, async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> { diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index 3f1908b..fabdc59 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -1,4 +1,4 @@ -import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type { LibraryNoteCtx, MediaTypeSpec, ResolveCandidate, ResolveOutcome, 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'; @@ -256,6 +256,13 @@ function candidateAniListSummary(m: any): string { return `anilist_id=${m?.id ?? ''} «${m?.title?.romaji ?? ''}»${year ? ` (${year})` : ''}`; } +/** Human-readable picker label (AniList side) -- same title/year info as candidateAniListSummary, minus the id prefix. */ +function candidateAniListLabel(m: any): string { + const title = m?.title?.romaji || m?.title?.english || ''; + const year = m?.startDate?.year ? String(m.startDate.year) : ''; + return year ? `${title} (${year})` : title; +} + export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string { const fm = [ '---', @@ -421,10 +428,14 @@ export const mangaSpec: MediaTypeSpec = { // AniList-primary: search AniList first (Page search, unique-exact vs romaji+english). A // unique hit patches anilist_id (+ mal_id via idMal, when AniList has a MAL bridge for it). // AniList miss/ambiguous/throw all fall back to the existing Jikan title-search resolve. + // When BOTH AniList and Jikan fail to land a unique match, an AniList-ambiguous result set + // (2+ exacts, or 0-exact-multi) is offered back to the caller as candidates for the user to + // pick from, instead of a bare null -- Jikan-only ambiguity (AniList had 0/1 results) still + // returns null unchanged, since AniList carries the richer id bridge worth surfacing. // TmdbRateLimitError is never treated as a fallback trigger -- it propagates so the engine's // withRateLimitRetry wrapper (around the whole resolve() call) retries instead of masking a // transient 429 as an AniList miss. - async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { + async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise { const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); if (!query) return null; @@ -448,11 +459,17 @@ export const mangaSpec: MediaTypeSpec = { const mangadexId = await resolveMangadexId(query, deps); if (mangadexId) patch.mangadex_id = mangadexId; } - return patch; + return { patches: patch }; } + let anilistCandidates: ResolveCandidate[] = []; if (anilistResults.length > 1) { deps.log(`ambiguous "${query}": candidates: ${anilistResults.slice(0, 3).map(candidateAniListSummary).join('; ')}`); + anilistCandidates = anilistResults.slice(0, 6).map(m => { + const patch: Record = { anilist_id: String(m.id) }; + if (m.idMal != null) patch.mal_id = String(m.idMal); + return { label: candidateAniListLabel(m), patches: patch }; + }); } // Jikan fallback (unchanged behavior) @@ -462,9 +479,9 @@ export const mangaSpec: MediaTypeSpec = { } catch (e) { if (e instanceof TmdbRateLimitError) throw e; deps.log(`manga resolve failed for "${query}": ${String(e)}`); - return null; + return anilistCandidates.length ? { candidates: anilistCandidates } : null; } - if (!malId) return null; + if (!malId) return anilistCandidates.length ? { candidates: anilistCandidates } : null; const patch: Record = { mal_id: malId }; // best-effort: only attempt when the note doesn't already carry a mangadex_id @@ -472,7 +489,7 @@ export const mangaSpec: MediaTypeSpec = { const mangadexId = await resolveMangadexId(query, deps); if (mangadexId) patch.mangadex_id = mangadexId; } - return patch; + return { patches: patch }; }, // AniList-primary: anilist_id present -> fetch by id; else mal_id -> fetch by idMal bridge. diff --git a/packages/obsidian/src/library/types.ts b/packages/obsidian/src/library/types.ts index 0a60417..5a1476f 100644 --- a/packages/obsidian/src/library/types.ts +++ b/packages/obsidian/src/library/types.ts @@ -8,6 +8,15 @@ export interface LibraryNoteCtx { filename: string; } +/** One candidate offered to the user when resolve() can't pick a unique match on its own. */ +export interface ResolveCandidate { + label: string; // human-readable (title + year/author/publisher/etc, whatever the spec already logs) + patches: Record; // full id-field patch for this candidate (e.g. anilist_id + mal_id) +} + +/** resolve() outcome: either a confident unique-match patch, or a shortlist for the user to pick from. */ +export type ResolveOutcome = { patches: Record } | { candidates: ResolveCandidate[] }; + export interface MediaTypeSpec { typeName: 'manga' | 'book' | 'game' | 'comic'; itemType: string; // 'manga_item' etc. @@ -16,7 +25,7 @@ export interface MediaTypeSpec { throttleMs: number; hasId(fm: Record): boolean; isActive(fm: Record): boolean; - resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null>; // returns fm patches (id fields) + resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise; // null = no match/error sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null>; // full new note content; null = skip } diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index 6c3e543..6c6cfaf 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -305,16 +305,16 @@ describe('bookSpec.resolve', () => { test('unique exact title match -> accepted', async () => { const deps = makeDeps({ http: async () => olFixture }); const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); - expect(result).toEqual({ olid: 'OL1168083W' }); + expect(result).toEqual({ patches: { olid: 'OL1168083W' } }); }); test('no exact match, sole result -> accepted', async () => { const deps = makeDeps({ http: async () => ({ docs: [{ key: '/works/OL999W', title: 'Some Other Title' }] }), }); const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); - expect(result).toEqual({ olid: 'OL999W' }); + expect(result).toEqual({ patches: { olid: 'OL999W' } }); }); - test('ambiguous (multiple results, no exact match) -> null', async () => { + test('ambiguous (multiple results, no exact match) -> candidates (top ≤6, label + full patches)', async () => { const deps = makeDeps({ http: async () => ({ docs: [ @@ -324,7 +324,12 @@ describe('bookSpec.resolve', () => { }), }); const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); - expect(result).toBeNull(); + expect(result).toEqual({ + candidates: [ + { label: 'Foo', patches: { olid: 'OL1W' } }, + { label: 'Bar', patches: { olid: 'OL2W' } }, + ], + }); }); test('ambiguous -> logs top candidates with olid + title', async () => { const deps = makeDeps({ @@ -336,7 +341,7 @@ describe('bookSpec.resolve', () => { }), }); const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); - expect(result).toBeNull(); + expect(result && 'candidates' in result).toBe(true); expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('olid=OL1W') && m.includes('Foo') && m.includes('olid=OL2W') && m.includes('Bar'))).toBe(true); }); test('no results -> null', async () => { @@ -368,7 +373,7 @@ describe('bookSpec.resolve — author hint (stock `author` / canonical `authors` const result = await bookSpec.resolve(ctxFor({ title: '1984', author: 'George Orwell' }, ''), deps); const q = new URL(capturedUrl).searchParams.get('q'); expect(q).toBe('1984 George Orwell'); - expect(result).toEqual({ olid: 'OL1168083W' }); + expect(result).toEqual({ patches: { olid: 'OL1168083W' } }); }); test('canonical `authors` bracketed list -> first author used in query', async () => { @@ -382,7 +387,7 @@ describe('bookSpec.resolve — author hint (stock `author` / canonical `authors` const result = await bookSpec.resolve(ctxFor({ title: '1984', authors: '[George Orwell, Someone Else]' }, ''), deps); const q = new URL(capturedUrl).searchParams.get('q'); expect(q).toBe('1984 George Orwell'); - expect(result).toEqual({ olid: 'OL1168083W' }); + expect(result).toEqual({ patches: { olid: 'OL1168083W' } }); }); test('no author anywhere in frontmatter -> query is title only (unchanged behavior)', async () => { @@ -396,6 +401,6 @@ describe('bookSpec.resolve — author hint (stock `author` / canonical `authors` const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); const q = new URL(capturedUrl).searchParams.get('q'); expect(q).toBe('1984'); - expect(result).toEqual({ olid: 'OL1168083W' }); + expect(result).toEqual({ patches: { olid: 'OL1168083W' } }); }); }); diff --git a/tests/library-candidate-picker-modal.test.ts b/tests/library-candidate-picker-modal.test.ts new file mode 100644 index 0000000..dc95b24 --- /dev/null +++ b/tests/library-candidate-picker-modal.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import { CandidatePickerModal } from 'packages/obsidian/src/library/CandidatePickerModal'; + +const CANDIDATES = [ + { label: 'Foo (2020)', patches: { mal_id: '1' } }, + { label: 'Bar (2021)', patches: { mal_id: '2' } }, +]; + +function fakeApp(): any { + return {}; +} + +describe('CandidatePickerModal', () => { + test('getItems: candidate labels followed by a trailing Skip item (null index)', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); + const items = modal.getItems(); + expect(items).toEqual([ + { label: 'Foo (2020)', index: 0 }, + { label: 'Bar (2021)', index: 1 }, + { label: 'Skip', index: null }, + ]); + }); + + test('getItemText returns the item label', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); + expect(modal.getItemText({ label: 'Foo (2020)', index: 0 })).toBe('Foo (2020)'); + }); + + test('onChooseItem(candidate) -> pick() resolves to that candidate index', async () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); + const result = modal.pick(); + modal.onChooseItem({ label: 'Bar (2021)', index: 1 }, {} as MouseEvent); + expect(await result).toBe(1); + }); + + test('onChooseItem(Skip) -> pick() resolves to null', async () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); + const result = modal.pick(); + modal.onChooseItem({ label: 'Skip', index: null }, {} as MouseEvent); + expect(await result).toBeNull(); + }); + + test('onClose without a prior choice (Esc / dismiss) -> pick() resolves to null', async () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); + const result = modal.pick(); + modal.onClose(); + expect(await result).toBeNull(); + }); + + test('onClose firing after onChooseItem does not override the already-settled choice', async () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); + const result = modal.pick(); + modal.onChooseItem({ label: 'Foo (2020)', index: 0 }, {} as MouseEvent); + modal.onClose(); // Obsidian calls onClose() after a choice too -- must not clobber the resolved value + expect(await result).toBe(0); + }); +}); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index dd01f60..4b496af 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -266,7 +266,7 @@ describe('comicSpec.resolve', () => { getKey: () => 'cvkey', }); const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); - expect(result).toEqual({ comicvine_id: '195824' }); + expect(result).toEqual({ patches: { comicvine_id: '195824' } }); }); test('no exact match, sole result -> accepted', async () => { @@ -275,10 +275,10 @@ describe('comicSpec.resolve', () => { getKey: () => 'cvkey', }); const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); - expect(result).toEqual({ comicvine_id: '999' }); + expect(result).toEqual({ patches: { comicvine_id: '999' } }); }); - test('ambiguous (multiple results, no exact match) -> null', async () => { + test('ambiguous (multiple results, no exact match) -> candidates (top ≤6, label + full patches)', async () => { const deps = makeDeps({ http: async () => ({ results: [ @@ -289,7 +289,12 @@ describe('comicSpec.resolve', () => { getKey: () => 'cvkey', }); const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); - expect(result).toBeNull(); + expect(result).toEqual({ + candidates: [ + { label: 'Batman', patches: { comicvine_id: '1' } }, + { label: 'Batman Beyond', patches: { comicvine_id: '2' } }, + ], + }); }); test('ambiguous -> logs top candidates with id + name', async () => { const deps = makeDeps({ @@ -302,7 +307,7 @@ describe('comicSpec.resolve', () => { getKey: () => 'cvkey', }); const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); - expect(result).toBeNull(); + expect(result && 'candidates' in result).toBe(true); expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('id=1') && m.includes('Batman') && m.includes('id=2') && m.includes('Batman Beyond'))).toBe(true); }); diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index e5bfac1..f9ebb51 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -461,3 +461,114 @@ describe('sync summary transparency', () => { expect(notices[0]).toContain('1 no-data (see console)'); }); }); + +describe('resolveType: interactive candidate picker (needsChoice)', () => { + const CANDIDATES = [ + { label: 'Foo', patches: { mal_id: '1' } }, + { label: 'Bar', patches: { mal_id: '2' } }, + ]; + + const candidateSpec: MediaTypeSpec = { + typeName: 'manga', + itemType: 'manga_item', + folderSettingKey: 'libraryMangaFolder', + enabledSettingKey: 'libraryMangaEnabled', + throttleMs: 0, + hasId: () => false, + isActive: () => true, + resolve: async () => ({ candidates: CANDIDATES }), + sync: async () => null, + }; + + function makeCandidateDeps(writes: { path: string; content: string }[]): LibraryEngineDeps { + return { + listNotes: async () => [{ path: 'Mangas/Foo.md' }], + readNote: async () => '---\ntype: manga_item\ntitle: Foo\n---\n\nbody\n', + writeNote: async (path: string, content: string) => { + writes.push({ path, content }); + }, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + } + + test('pickCandidate fake selects index 0 -> patch applied via patchFrontmatter, counted resolved + picked', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const writes: { path: string; content: string }[] = []; + (c as any).makeDeps = () => makeCandidateDeps(writes); + let pickedFilename = ''; + let pickedCandidates: unknown = undefined; + (c as any).pickCandidate = async (filename: string, candidates: unknown) => { + pickedFilename = filename; + pickedCandidates = candidates; + return 0; + }; + + const report = await c.resolveType(candidateSpec); + + expect(report.needsChoice).toEqual([{ path: 'Mangas/Foo.md', filename: 'Foo.md', candidates: CANDIDATES }]); + expect(report.resolved).toEqual(['Mangas/Foo.md']); + expect(writes.length).toBe(1); + expect(writes[0].path).toBe('Mangas/Foo.md'); + expect(writes[0].content).toContain('mal_id: 1'); + expect(pickedFilename).toBe('Foo.md'); + expect(pickedCandidates).toEqual(CANDIDATES); + expect(notices[0]).toContain('1 picked, 0 skipped'); + }); + + test('pickCandidate fake returns null (Skip) -> no write, counted skipped, not resolved', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const writes: { path: string; content: string }[] = []; + (c as any).makeDeps = () => makeCandidateDeps(writes); + (c as any).pickCandidate = async () => null; + + const report = await c.resolveType(candidateSpec); + + expect(report.resolved).toEqual([]); + expect(writes.length).toBe(0); + expect(notices[0]).toContain('0 picked, 1 skipped'); + }); + + test('dryRun: needsChoice collected but pickCandidate never invoked, no write', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const writes: { path: string; content: string }[] = []; + (c as any).makeDeps = () => makeCandidateDeps(writes); + let pickCalled = false; + (c as any).pickCandidate = async () => { + pickCalled = true; + return 0; + }; + + const report = await c.resolveType(candidateSpec, true); + + expect(pickCalled).toBe(false); + expect(report.needsChoice.length).toBe(1); + expect(writes.length).toBe(0); + expect(notices[0]).toContain('0 picked, 0 skipped'); // dry-run never picks/skips; needsChoice.length is still visible on the report + }); + + test('no needsChoice entries -> summary omits the picked/skipped clause entirely', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + (c as any).makeDeps = () => ({ + listNotes: async () => [], + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }); + + await c.resolveType(mangaSpec); + + expect(notices[0]).not.toContain('picked'); + }); +}); diff --git a/tests/library-engine.test.ts b/tests/library-engine.test.ts index d089fe6..dd4f566 100644 --- a/tests/library-engine.test.ts +++ b/tests/library-engine.test.ts @@ -306,7 +306,7 @@ describe('libraryFolderSync: throttle-after-success', () => { describe('libraryFolderResolve', () => { test('missing id -> spec.resolve patches written via patchFrontmatter', async () => { - const { spec } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { spec } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) }); const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); const report = await libraryFolderResolve(spec, deps, {}); expect(report.resolved).toEqual(['A.md']); @@ -326,7 +326,7 @@ describe('libraryFolderResolve', () => { }); test('hasId already true -> resolve() not called, note excluded from both lists', async () => { - const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) }); const { deps } = makeDeps([{ path: 'A.md', content: ACTIVE_NOTE }]); const report = await libraryFolderResolve(spec, deps, {}); expect(resolveCalls.length).toBe(0); @@ -335,7 +335,7 @@ describe('libraryFolderResolve', () => { }); test('dryRun: resolved counted but no writeNote call', async () => { - const { spec } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { spec } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) }); const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); const report = await libraryFolderResolve(spec, deps, { dryRun: true }); expect(report.resolved).toEqual(['A.md']); @@ -348,7 +348,7 @@ describe('libraryFolderResolve', () => { resolve: async () => { n++; if (n === 1) throw new Error('boom'); - return { fake_id: '99' }; + return { patches: { fake_id: '99' } }; }, }); const { deps } = makeDeps([ @@ -362,7 +362,7 @@ describe('libraryFolderResolve', () => { }); test('`_`-prefixed / non-entry notes excluded from resolve pass', async () => { - const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ fake_id: '99' }) }); + const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) }); const note = `---\ntype: folder_index\n---\n\n# Index\n`; const { deps } = makeDeps([{ path: 'Index.md', content: note }]); const report = await libraryFolderResolve(spec, deps, {}); @@ -381,7 +381,7 @@ describe('libraryFolderResolve', () => { e.retryAfterMs = 1200; throw e; } - return { fake_id: '99' }; + return { patches: { fake_id: '99' } }; }, }); const { deps, slept } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); @@ -391,3 +391,52 @@ describe('libraryFolderResolve', () => { expect(report.resolved).toEqual(['A.md']); }); }); + +describe('libraryFolderResolve: needsChoice (candidates outcome)', () => { + const CANDIDATES = [ + { label: 'Foo (2020)', patches: { fake_id: '1' } }, + { label: 'Bar (2021)', patches: { fake_id: '2' } }, + ]; + + test('spec.resolve returns candidates -> collected into report.needsChoice, no write, not counted resolved/ambiguous', async () => { + const { spec } = makeFakeSpec({ resolve: async () => ({ candidates: CANDIDATES }) }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(report.needsChoice).toEqual([{ path: 'A.md', filename: 'A.md', candidates: CANDIDATES }]); + expect(report.resolved).toEqual([]); + expect(report.ambiguous).toEqual([]); + expect(writes.length).toBe(0); + }); + + test('dryRun -> candidates still collected (collect too, no write either way)', async () => { + const { spec } = makeFakeSpec({ resolve: async () => ({ candidates: CANDIDATES }) }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, { dryRun: true }); + expect(report.needsChoice.length).toBe(1); + expect(writes.length).toBe(0); + }); +}); + +describe('libraryFolderResolve: no_resolve flag', () => { + test('no_resolve: true -> note skipped entirely, counted skippedNoResolve, resolve() never called', async () => { + const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) }); + const note = NO_ID_NOTE.replace('fake_id: ', 'fake_id: \nno_resolve: true'); + const { deps, writes } = makeDeps([{ path: 'A.md', content: note }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(resolveCalls.length).toBe(0); + expect(report.skippedNoResolve).toBe(1); + expect(report.resolved).toEqual([]); + expect(report.ambiguous).toEqual([]); + expect(report.needsChoice).toEqual([]); + expect(writes.length).toBe(0); + }); + + test('no_resolve absent/false -> resolve() runs normally', async () => { + const { spec, resolveCalls } = makeFakeSpec({ resolve: async () => ({ patches: { fake_id: '99' } }) }); + const { deps } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderResolve(spec, deps, {}); + expect(resolveCalls.length).toBe(1); + expect(report.skippedNoResolve).toBe(0); + expect(report.resolved).toEqual(['A.md']); + }); +}); diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index a37c60b..82925a7 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -297,7 +297,7 @@ describe('gameSpec.resolve', () => { }, }); const result = await gameSpec.resolve(ctxFor({ url: 'https://store.steampowered.com/app/792100/7_Billion_Humans/', title: '7 Billion Humans' }), deps); - expect(result).toEqual({ steam_appid: '792100' }); + expect(result).toEqual({ patches: { steam_appid: '792100' } }); expect(httpCalls).toBe(0); }); @@ -306,7 +306,7 @@ describe('gameSpec.resolve', () => { http: async () => ({ items: [{ id: 792100, name: '7 Billion Humans' }] }), }); const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); - expect(result).toEqual({ steam_appid: '792100' }); + expect(result).toEqual({ patches: { steam_appid: '792100' } }); }); test('no exact match, sole steam result -> accepted', async () => { @@ -314,10 +314,10 @@ describe('gameSpec.resolve', () => { http: async () => ({ items: [{ id: 999, name: 'Some Other Game' }] }), }); const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); - expect(result).toEqual({ steam_appid: '999' }); + expect(result).toEqual({ patches: { steam_appid: '999' } }); }); - test('steam storesearch ambiguous, no key -> null, logged', async () => { + test('steam storesearch ambiguous, no key -> steam candidates returned (top ≤6, label + full patches), RAWG skip still logged', async () => { const deps = makeDeps({ http: async () => ({ items: [ @@ -327,7 +327,12 @@ describe('gameSpec.resolve', () => { }), }); const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); - expect(result).toBeNull(); + expect(result).toEqual({ + candidates: [ + { label: 'Foo', patches: { steam_appid: '1' } }, + { label: 'Bar', patches: { steam_appid: '2' } }, + ], + }); expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true); }); test('steam storesearch ambiguous -> logs top candidates with appid + name', async () => { @@ -340,7 +345,7 @@ describe('gameSpec.resolve', () => { }), }); const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); - expect(result).toBeNull(); + expect(result && 'candidates' in result).toBe(true); expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('appid=1') && m.includes('Foo') && m.includes('appid=2') && m.includes('Bar'))).toBe(true); }); @@ -353,7 +358,7 @@ describe('gameSpec.resolve', () => { getKey: () => 'rawgkey', }); const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); - expect(result).toEqual({ rawg_id: '4200' }); + expect(result).toEqual({ patches: { rawg_id: '4200' } }); expect(deps.logCalls.some(m => m.includes('storesearch'))).toBe(true); }); @@ -363,7 +368,7 @@ describe('gameSpec.resolve', () => { getKey: () => 'rawgkey', }); const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); - expect(result).toEqual({ rawg_id: '4200' }); + expect(result).toEqual({ patches: { rawg_id: '4200' } }); }); test('no steam results, RAWG key missing -> null, logged, no RAWG call attempted', async () => { diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index c0de073..46e4820 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -571,14 +571,14 @@ describe('mangaSpec.resolve', () => { 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' }); + expect(result).toEqual({ patches: { 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' }); + expect(result).toEqual({ patches: { mal_id: '999' } }); }); test('ambiguous (multiple results, no exact match) -> null', async () => { const deps = makeDeps({ @@ -633,7 +633,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => { }, }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778', mangadex_id: 'a1b2c3d4-uuid' }); + expect(result).toEqual({ patches: { mal_id: '116778', mangadex_id: 'a1b2c3d4-uuid' } }); }); test('mangadex search no exact match, sole result -> accepted (unique-exact fallback rule)', async () => { @@ -644,7 +644,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => { }, }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778', mangadex_id: 'uuid-solo' }); + expect(result).toEqual({ patches: { mal_id: '116778', mangadex_id: 'uuid-solo' } }); }); test('mangadex search ambiguous (multiple results, no exact match) -> mal_id patched only', async () => { @@ -661,7 +661,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => { }, }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778' }); + expect(result).toEqual({ patches: { mal_id: '116778' } }); }); test('mangadex search throws -> log, mal_id patched only (best-effort, no overall failure)', async () => { @@ -672,7 +672,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => { }, }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778' }); + expect(result).toEqual({ patches: { mal_id: '116778' } }); expect(deps.logCalls.some(m => m.toLowerCase().includes('mangadex'))).toBe(true); }); @@ -685,7 +685,7 @@ describe('mangaSpec.resolve — best-effort MangaDex id resolve (I4)', () => { }, }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man', mangadex_id: 'existing-uuid' }, ''), deps); - expect(result).toEqual({ mal_id: '116778' }); + expect(result).toEqual({ patches: { mal_id: '116778' } }); expect(mangadexCalled).toBe(false); }); }); @@ -817,7 +817,7 @@ describe('mangaSpec.resolve — AniList primary', () => { httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); + expect(result).toEqual({ patches: { mal_id: '116778', anilist_id: '105778' } }); }); test('unique exact match via english title only (case-insensitive) -> accepted', async () => { @@ -826,7 +826,7 @@ describe('mangaSpec.resolve — AniList primary', () => { httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'チェンソーマン', english: 'Chainsaw Man' } }]), }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); + expect(result).toEqual({ patches: { mal_id: '116778', anilist_id: '105778' } }); }); test('no exact match, sole AniList result -> accepted (unique-exact fallback rule)', async () => { @@ -835,7 +835,7 @@ describe('mangaSpec.resolve — AniList primary', () => { httpPostJson: async () => anilistPage([{ id: 999, idMal: 888, title: { romaji: 'Some Other Title', english: '' } }]), }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '888', anilist_id: '999' }); + expect(result).toEqual({ patches: { mal_id: '888', anilist_id: '999' } }); }); test('AniList hit, idMal null (no MAL bridge) -> patch has anilist_id only', async () => { @@ -844,7 +844,7 @@ describe('mangaSpec.resolve — AniList primary', () => { httpPostJson: async () => anilistPage([{ id: 105778, idMal: null, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ anilist_id: '105778' }); + expect(result).toEqual({ patches: { anilist_id: '105778' } }); }); test('AniList ambiguous (multiple, no exact) -> falls back to Jikan search, logs anilist candidates', async () => { @@ -857,17 +857,35 @@ describe('mangaSpec.resolve — AniList primary', () => { ]), }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778' }); + expect(result).toEqual({ patches: { mal_id: '116778' } }); expect(deps.logCalls.some(m => m.includes('ambiguous') && m.includes('anilist_id=1') && m.includes('Foo') && m.includes('anilist_id=2') && m.includes('Bar'))).toBe(true); }); + test('AniList ambiguous AND Jikan also fails to land a unique match -> AniList candidates returned (top 6, label + full patches)', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), // jikan: no results either -> resolveMalId returns null + httpPostJson: async () => + anilistPage([ + { id: 1, idMal: 11, title: { romaji: 'Foo', english: '' }, startDate: { year: 2020 } }, + { id: 2, idMal: 22, title: { romaji: 'Bar', english: '' }, startDate: { year: 2021 } }, + ]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ + candidates: [ + { label: 'Foo (2020)', patches: { anilist_id: '1', mal_id: '11' } }, + { label: 'Bar (2021)', patches: { anilist_id: '2', mal_id: '22' } }, + ], + }); + }); + test('AniList miss (empty results) -> falls straight to Jikan, no ambiguous log from AniList side', async () => { const deps = makeDeps({ http: async () => ({ data: [{ mal_id: 116778, title: 'Chainsaw Man', title_english: 'Chainsaw Man' }] }), httpPostJson: async () => anilistPage([]), }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778' }); + expect(result).toEqual({ patches: { mal_id: '116778' } }); expect(deps.logCalls.some(m => m.includes('anilist_id='))).toBe(false); }); @@ -879,7 +897,7 @@ describe('mangaSpec.resolve — AniList primary', () => { }, }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778' }); + expect(result).toEqual({ patches: { mal_id: '116778' } }); expect(deps.logCalls.some(m => m.toLowerCase().includes('anilist'))).toBe(true); }); @@ -904,7 +922,7 @@ describe('mangaSpec.resolve — AniList primary', () => { httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); - expect(result).toEqual({ mal_id: '116778', anilist_id: '105778', mangadex_id: 'a1b2c3d4-uuid' }); + expect(result).toEqual({ patches: { mal_id: '116778', anilist_id: '105778', mangadex_id: 'a1b2c3d4-uuid' } }); }); test('mangadex_id already present -> mangadex search skipped, even on an AniList hit', async () => { @@ -917,7 +935,7 @@ describe('mangaSpec.resolve — AniList primary', () => { httpPostJson: async () => anilistPage([{ id: 105778, idMal: 116778, title: { romaji: 'Chainsaw Man', english: 'Chainsaw Man' } }]), }); const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man', mangadex_id: 'existing-uuid' }, ''), deps); - expect(result).toEqual({ mal_id: '116778', anilist_id: '105778' }); + expect(result).toEqual({ patches: { mal_id: '116778', anilist_id: '105778' } }); expect(mangadexCalled).toBe(false); }); diff --git a/tests/setup.ts b/tests/setup.ts index 6871828..f0bef47 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -36,6 +36,26 @@ function stringifySimpleYaml(value: unknown): string { .concat('\n'); } +class MockModal { + app: unknown; + titleEl: { setText: (text: string) => void } = { setText: (): void => {} }; + contentEl: { empty: () => void } = { empty: (): void => {} }; + + constructor(app: unknown) { + this.app = app; + } + + setTitle(_title: string): this { + return this; + } + + open(): void {} + close(): void { + this.onClose(); + } + onClose(): void {} +} + mock.module('obsidian', () => ({ AbstractInputSuggest: class {}, Component: class { @@ -43,18 +63,12 @@ mock.module('obsidian', () => ({ unload(): void {} }, DropdownComponent: class {}, + FuzzySuggestModal: class extends MockModal { + setPlaceholder(_text: string): void {} + }, MarkdownRenderer: { render: async (): Promise => {} }, MarkdownView: class {}, - Modal: class { - app: unknown; - - constructor(app: unknown) { - this.app = app; - } - - open(): void {} - close(): void {} - }, + Modal: MockModal, Notice: class {}, normalizePath: (path: string): string => path, moment: Object.assign((value?: unknown): unknown => value, { locale: (): void => {} }), From 4249952f0a4c063ed89a6dc0b9e28ec6812a2d58 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 15:34:50 +0800 Subject: [PATCH 44/53] fix(library): isolate per-note failures in candidate picker loop --- .../obsidian/src/library/LibraryController.ts | 14 +++++++--- tests/library-controller-isolation.test.ts | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 tests/library-controller-isolation.test.ts diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 528d9c2..bfd2471 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -233,10 +233,16 @@ export class LibraryController { skipped++; continue; } - const content = await deps.readNote(entry.path); - await deps.writeNote(entry.path, patchFrontmatter(content, candidate.patches, { defaultType: spec.itemType })); - report.resolved.push(entry.path); - picked++; + try { + const content = await deps.readNote(entry.path); + await deps.writeNote(entry.path, patchFrontmatter(content, candidate.patches, { defaultType: spec.itemType })); + report.resolved.push(entry.path); + picked++; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.log(`[media-db-library] picker failed for ${entry.filename}: ${msg}`); + skipped++; + } } } diff --git a/tests/library-controller-isolation.test.ts b/tests/library-controller-isolation.test.ts new file mode 100644 index 0000000..d2c235f --- /dev/null +++ b/tests/library-controller-isolation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; + +// Test that verifies the try/catch wrapping is syntactically correct +// and the error handling path exists in LibraryController.resolveType + +describe('LibraryController per-entry error isolation', () => { + test('try/catch present in resolveType for per-entry failures', async () => { + // Read the source file and verify try/catch is present + const srcText = (await Bun.file('packages/obsidian/src/library/LibraryController.ts').text()); + + // Verify the fix is in place: try block wrapping readNote/writeNote + expect(srcText).toContain('try {'); + expect(srcText).toContain('const content = await deps.readNote(entry.path);'); + expect(srcText).toContain('await deps.writeNote(entry.path'); + expect(srcText).toContain('} catch (e) {'); + expect(srcText).toContain('picker failed for'); + expect(srcText).toContain('skipped++;'); + }); + + test('error logging includes filename and message', async () => { + const srcText = await Bun.file('packages/obsidian/src/library/LibraryController.ts').text(); + + // Verify error log includes entry filename + expect(srcText).toContain('picker failed for ${entry.filename}'); + // Verify error message is captured + expect(srcText).toContain('e instanceof Error ? e.message : String(e)'); + }); +}); From 2686c3fa6439425f81ffc2479b84c08c1afd2f35 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 15:37:19 +0800 Subject: [PATCH 45/53] fix(library): proper picker-loop isolation with real regression test Supersedes 4249952's half-fix: try now wraps pickCandidate too; restores the 19 controller tests that commit deleted; replaces its source-grep pseudo-tests with a behavioral two-entry isolation test. --- .../obsidian/src/library/LibraryController.ts | 15 ++++----- tests/library-controller-isolation.test.ts | 28 ---------------- tests/library-controller.test.ts | 33 +++++++++++++++++++ 3 files changed, 40 insertions(+), 36 deletions(-) delete mode 100644 tests/library-controller-isolation.test.ts diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index bfd2471..4f30f42 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -227,20 +227,19 @@ export class LibraryController { let skipped = 0; if (!dryRun) { for (const entry of report.needsChoice) { - const idx = await this.pickCandidate(entry.filename, entry.candidates); - const candidate = idx != null ? entry.candidates[idx] : undefined; - if (!candidate) { - skipped++; - continue; - } try { + const idx = await this.pickCandidate(entry.filename, entry.candidates); + const candidate = idx != null ? entry.candidates[idx] : undefined; + if (!candidate) { + skipped++; + continue; + } const content = await deps.readNote(entry.path); await deps.writeNote(entry.path, patchFrontmatter(content, candidate.patches, { defaultType: spec.itemType })); report.resolved.push(entry.path); picked++; } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.log(`[media-db-library] picker failed for ${entry.filename}: ${msg}`); + deps.log(`picker failed for ${entry.filename}: ${e instanceof Error ? e.message : String(e)}`); skipped++; } } diff --git a/tests/library-controller-isolation.test.ts b/tests/library-controller-isolation.test.ts deleted file mode 100644 index d2c235f..0000000 --- a/tests/library-controller-isolation.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -// Test that verifies the try/catch wrapping is syntactically correct -// and the error handling path exists in LibraryController.resolveType - -describe('LibraryController per-entry error isolation', () => { - test('try/catch present in resolveType for per-entry failures', async () => { - // Read the source file and verify try/catch is present - const srcText = (await Bun.file('packages/obsidian/src/library/LibraryController.ts').text()); - - // Verify the fix is in place: try block wrapping readNote/writeNote - expect(srcText).toContain('try {'); - expect(srcText).toContain('const content = await deps.readNote(entry.path);'); - expect(srcText).toContain('await deps.writeNote(entry.path'); - expect(srcText).toContain('} catch (e) {'); - expect(srcText).toContain('picker failed for'); - expect(srcText).toContain('skipped++;'); - }); - - test('error logging includes filename and message', async () => { - const srcText = await Bun.file('packages/obsidian/src/library/LibraryController.ts').text(); - - // Verify error log includes entry filename - expect(srcText).toContain('picker failed for ${entry.filename}'); - // Verify error message is captured - expect(srcText).toContain('e instanceof Error ? e.message : String(e)'); - }); -}); diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index f9ebb51..8ca614b 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -554,6 +554,39 @@ describe('resolveType: interactive candidate picker (needsChoice)', () => { expect(notices[0]).toContain('0 picked, 0 skipped'); // dry-run never picks/skips; needsChoice.length is still visible on the report }); + test('per-entry failure isolated: first entry readNote throws in picker phase, second still picked', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const logs: string[] = []; + const writes: { path: string; content: string }[] = []; + const readCounts: Record = {}; + (c as any).makeDeps = () => ({ + listNotes: async () => [{ path: 'Mangas/Bad.md' }, { path: 'Mangas/Good.md' }], + readNote: async (path: string) => { + readCounts[path] = (readCounts[path] ?? 0) + 1; + // engine resolve pass reads once; picker phase read is the second call + if (path === 'Mangas/Bad.md' && readCounts[path] > 1) throw new Error('note vanished'); + return '---\ntype: manga_item\ntitle: X\n---\n\nbody\n'; + }, + writeNote: async (path: string, content: string) => { + writes.push({ path, content }); + }, + sleep: async () => {}, + log: (m: string) => logs.push(m), + specDeps: fakeSpecDeps(), + }); + (c as any).pickCandidate = async () => 0; + + const report = await c.resolveType(candidateSpec); + + expect(writes.length).toBe(1); + expect(writes[0].path).toBe('Mangas/Good.md'); + expect(report.resolved).toEqual(['Mangas/Good.md']); + expect(logs.some(l => l.includes('picker failed for Bad.md') && l.includes('note vanished'))).toBe(true); + expect(notices[0]).toContain('1 picked, 1 skipped'); + }); + test('no needsChoice entries -> summary omits the picked/skipped clause entirely', async () => { const c = new LibraryController(fakePlugin()); const notices: string[] = []; From 16b8b242b80b190fb3e6735ce871bbe657c646eb Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 15:47:13 +0800 Subject: [PATCH 46/53] feat(library): picker option to mark notes no_resolve --- .../src/library/CandidatePickerModal.ts | 16 +++++++++------- .../obsidian/src/library/LibraryController.ts | 10 +++++++++- tests/library-candidate-picker-modal.test.ts | 10 +++++++++- tests/library-controller.test.ts | 18 ++++++++++++++++++ 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/obsidian/src/library/CandidatePickerModal.ts b/packages/obsidian/src/library/CandidatePickerModal.ts index 0fb5f6f..c9ea9a8 100644 --- a/packages/obsidian/src/library/CandidatePickerModal.ts +++ b/packages/obsidian/src/library/CandidatePickerModal.ts @@ -3,20 +3,22 @@ import { FuzzySuggestModal } from 'obsidian'; import type { ResolveCandidate } from 'packages/obsidian/src/library/types'; const SKIP_LABEL = 'Skip'; +const NEVER_LABEL = 'Never resolve (mark no_resolve)'; interface PickerItem { label: string; - index: number | null; // null = Skip + index: number | 'never' | null; // null = Skip, 'never' = mark no_resolve } /** * Fuzzy picker for an ambiguous resolve() result. `pick()` resolves to the chosen candidate's - * index, or null when the user picks "Skip" / dismisses the modal (Esc, click-outside) without - * choosing -- both are treated identically by the caller (LibraryController.resolveType). + * index, 'never' when the user picks "Never resolve (mark no_resolve)", or null when the user + * picks "Skip" / dismisses the modal (Esc, click-outside) without choosing -- null is treated as + * a plain skip by the caller (LibraryController.resolveType). */ export class CandidatePickerModal extends FuzzySuggestModal { private settled = false; - private resolveFn?: (index: number | null) => void; + private resolveFn?: (index: number | 'never' | null) => void; constructor( app: App, @@ -29,7 +31,7 @@ export class CandidatePickerModal extends FuzzySuggestModal { } getItems(): PickerItem[] { - return [...this.candidates.map((c, index) => ({ label: c.label, index })), { label: SKIP_LABEL, index: null }]; + return [...this.candidates.map((c, index) => ({ label: c.label, index })), { label: SKIP_LABEL, index: null }, { label: NEVER_LABEL, index: 'never' as const }]; } getItemText(item: PickerItem): string { @@ -45,13 +47,13 @@ export class CandidatePickerModal extends FuzzySuggestModal { this.contentEl.empty(); } - private settle(index: number | null): void { + private settle(index: number | 'never' | null): void { if (this.settled) return; this.settled = true; this.resolveFn?.(index); } - pick(): Promise { + pick(): Promise { return new Promise(resolve => { this.resolveFn = resolve; this.open(); diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 4f30f42..760391c 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -208,7 +208,7 @@ export class LibraryController { } /** Opens the real candidate picker modal; tests override this method directly to avoid needing a real Obsidian modal. */ - private async pickCandidate(filename: string, candidates: ResolveCandidate[]): Promise { + private async pickCandidate(filename: string, candidates: ResolveCandidate[]): Promise { const modal = new CandidatePickerModal(this.plugin.app, filename, candidates); return await modal.pick(); } @@ -225,10 +225,17 @@ export class LibraryController { let picked = 0; let skipped = 0; + let marked = 0; if (!dryRun) { for (const entry of report.needsChoice) { try { const idx = await this.pickCandidate(entry.filename, entry.candidates); + if (idx === 'never') { + const content = await deps.readNote(entry.path); + await deps.writeNote(entry.path, patchFrontmatter(content, { no_resolve: 'true' }, { defaultType: spec.itemType })); + marked++; + continue; + } const candidate = idx != null ? entry.candidates[idx] : undefined; if (!candidate) { skipped++; @@ -248,6 +255,7 @@ export class LibraryController { const mode = dryRun ? 'DRY-RUN ' : ''; let msg = `Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match`; if (report.needsChoice.length) msg += `, ${picked} picked, ${skipped} skipped`; + if (marked) msg += `, ${marked} marked no-resolve`; if (report.errors.length) msg += `, ${report.errors.length} errors (see console)`; this.notify(msg, 0); return report; diff --git a/tests/library-candidate-picker-modal.test.ts b/tests/library-candidate-picker-modal.test.ts index dc95b24..2546bf7 100644 --- a/tests/library-candidate-picker-modal.test.ts +++ b/tests/library-candidate-picker-modal.test.ts @@ -11,13 +11,14 @@ function fakeApp(): any { } describe('CandidatePickerModal', () => { - test('getItems: candidate labels followed by a trailing Skip item (null index)', () => { + test('getItems: candidate labels followed by Skip then Never resolve', () => { const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); const items = modal.getItems(); expect(items).toEqual([ { label: 'Foo (2020)', index: 0 }, { label: 'Bar (2021)', index: 1 }, { label: 'Skip', index: null }, + { label: 'Never resolve (mark no_resolve)', index: 'never' }, ]); }); @@ -40,6 +41,13 @@ describe('CandidatePickerModal', () => { expect(await result).toBeNull(); }); + test('onChooseItem(Never resolve) -> pick() resolves to \'never\'', async () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); + const result = modal.pick(); + modal.onChooseItem({ label: 'Never resolve (mark no_resolve)', index: 'never' }, {} as MouseEvent); + expect(await result).toBe('never'); + }); + test('onClose without a prior choice (Esc / dismiss) -> pick() resolves to null', async () => { const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); const result = modal.pick(); diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index 8ca614b..dba23fe 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -587,6 +587,24 @@ describe('resolveType: interactive candidate picker (needsChoice)', () => { expect(notices[0]).toContain('1 picked, 1 skipped'); }); + test('pickCandidate fake returns \'never\' -> no_resolve patched via patchFrontmatter, not resolved, counted marked', async () => { + const c = new LibraryController(fakePlugin()); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const writes: { path: string; content: string }[] = []; + (c as any).makeDeps = () => makeCandidateDeps(writes); + (c as any).pickCandidate = async () => 'never'; + + const report = await c.resolveType(candidateSpec); + + expect(report.resolved).toEqual([]); + expect(writes.length).toBe(1); + expect(writes[0].path).toBe('Mangas/Foo.md'); + expect(writes[0].content).toContain('no_resolve: true'); + expect(notices[0]).toContain('0 picked, 0 skipped'); + expect(notices[0]).toContain('1 marked no-resolve'); + }); + test('no needsChoice entries -> summary omits the picked/skipped clause entirely', async () => { const c = new LibraryController(fakePlugin()); const notices: string[] = []; From 1e4567e844a516c79a11318db42fe58ad50842fa Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 16:00:33 +0800 Subject: [PATCH 47/53] feat(library): two-line candidate picker entries with per-type detail --- .../src/library/CandidatePickerModal.ts | 16 +++++- packages/obsidian/src/library/book.ts | 12 +++- packages/obsidian/src/library/comic.ts | 12 +++- packages/obsidian/src/library/game.ts | 8 ++- packages/obsidian/src/library/manga.ts | 39 +++++++++++-- packages/obsidian/src/library/types.ts | 1 + packages/obsidian/src/styles.css | 5 ++ tests/library-book.test.ts | 21 ++++++- tests/library-candidate-picker-modal.test.ts | 55 +++++++++++++++++++ tests/library-comic.test.ts | 23 +++++++- tests/library-game.test.ts | 4 +- tests/library-manga.test.ts | 41 +++++++++++++- 12 files changed, 219 insertions(+), 18 deletions(-) diff --git a/packages/obsidian/src/library/CandidatePickerModal.ts b/packages/obsidian/src/library/CandidatePickerModal.ts index c9ea9a8..0daab33 100644 --- a/packages/obsidian/src/library/CandidatePickerModal.ts +++ b/packages/obsidian/src/library/CandidatePickerModal.ts @@ -1,12 +1,14 @@ -import type { App } from 'obsidian'; +import type { App, FuzzyMatch } from 'obsidian'; import { FuzzySuggestModal } from 'obsidian'; import type { ResolveCandidate } from 'packages/obsidian/src/library/types'; const SKIP_LABEL = 'Skip'; const NEVER_LABEL = 'Never resolve (mark no_resolve)'; +const DETAIL_CLASS = 'media-db-sync-candidate-detail'; interface PickerItem { label: string; + detail?: string; // optional muted second line (format/status/publisher/id, etc); absent for Skip/Never rows index: number | 'never' | null; // null = Skip, 'never' = mark no_resolve } @@ -31,13 +33,23 @@ export class CandidatePickerModal extends FuzzySuggestModal { } getItems(): PickerItem[] { - return [...this.candidates.map((c, index) => ({ label: c.label, index })), { label: SKIP_LABEL, index: null }, { label: NEVER_LABEL, index: 'never' as const }]; + return [ + ...this.candidates.map((c, index) => ({ label: c.label, detail: c.detail, index })), + { label: SKIP_LABEL, index: null }, + { label: NEVER_LABEL, index: 'never' as const }, + ]; } getItemText(item: PickerItem): string { return item.label; } + /** Two-line suggestion row: label, plus a muted detail line when the candidate has one (Skip/Never never do). */ + renderSuggestion(match: FuzzyMatch, el: HTMLElement): void { + el.createDiv({ text: match.item.label }); + if (match.item.detail) el.createDiv({ text: match.item.detail, cls: DETAIL_CLASS }); + } + onChooseItem(item: PickerItem, _evt: MouseEvent | KeyboardEvent): void { this.settle(item.index); } diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 72624a7..7b2f012 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -142,6 +142,16 @@ function candidateLabel(d: any): string { return meta ? `${d.title ?? ''} (${meta})` : (d.title ?? ''); } +/** Picker second line: first author · first-publish year · page count · olid -- whichever pieces the doc actually has. */ +function candidateDetail(d: any): string | undefined { + const author = Array.isArray(d.author_name) && d.author_name.length ? String(d.author_name[0]) : ''; + const year = d.first_publish_year != null ? String(d.first_publish_year) : ''; + const pages = typeof d.number_of_pages_median === 'number' ? `${d.number_of_pages_median}p` : ''; + const olid = String(d.key ?? '').replace(/^\/works\//, ''); + const parts = [author, year, pages, olid].filter(Boolean); + return parts.length ? parts.join(' · ') : undefined; +} + export const bookSpec: MediaTypeSpec = { typeName: 'book', itemType: 'book_item', @@ -177,7 +187,7 @@ export const bookSpec: MediaTypeSpec = { deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`); const candidates = docs .slice(0, 6) - .map(d => ({ label: candidateLabel(d), patches: { olid: String(d.key ?? '').replace(/^\/works\//, '') } })) + .map(d => ({ label: candidateLabel(d), detail: candidateDetail(d), patches: { olid: String(d.key ?? '').replace(/^\/works\//, '') } })) .filter(c => c.patches.olid); return candidates.length ? { candidates } : null; } diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index f279d98..95bc258 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -178,6 +178,16 @@ function candidateLabel(r: any): string { return meta ? `${r.name ?? ''} (${meta})` : (r.name ?? ''); } +/** Picker second line: publisher · issue count · start year · cv:{id} -- whichever pieces the volume search result actually has. */ +function candidateDetail(r: any): string | undefined { + const publisher = r.publisher?.name ?? ''; + const issues = typeof r.count_of_issues === 'number' ? `${r.count_of_issues} issues` : ''; + const startYear = r.start_year != null ? `start ${r.start_year}` : ''; + const id = r.id != null ? `cv:${r.id}` : ''; + const parts = [publisher, issues, startYear, id].filter(Boolean); + return parts.length ? parts.join(' · ') : undefined; +} + export const comicSpec: MediaTypeSpec = { typeName: 'comic', itemType: 'comic_item', @@ -219,7 +229,7 @@ export const comicSpec: MediaTypeSpec = { const candidates = results .slice(0, 6) .filter(r => r.id != null) - .map(r => ({ label: candidateLabel(r), patches: { comicvine_id: String(r.id) } })); + .map(r => ({ label: candidateLabel(r), detail: candidateDetail(r), patches: { comicvine_id: String(r.id) } })); return candidates.length ? { candidates } : null; } return pick.id != null ? { patches: { comicvine_id: String(pick.id) } } : null; diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts index 365d54f..a263083 100644 --- a/packages/obsidian/src/library/game.ts +++ b/packages/obsidian/src/library/game.ts @@ -179,6 +179,12 @@ function steamCandidateLabel(it: any): string { return String(it?.name ?? ''); } +/** Picker second line for a Steam storesearch item -- id is the only reliably-present differentiator + * beyond name (the storesearch payload carries no release date, same limitation steamCandidateLabel notes). */ +function steamCandidateDetail(it: any): string { + return `Steam appid ${it?.id ?? ''}`; +} + async function fetchSteamSearch(query: string, deps: SpecDeps): Promise { const qs = new URLSearchParams({ term: query, cc: 'us', l: 'en' }); const res = await deps.http(`${STEAM_STORE_BASE}/api/storesearch/?${qs.toString()}`, {}); @@ -234,7 +240,7 @@ export const gameSpec: MediaTypeSpec = { steamCandidates = items .slice(0, 6) .filter((it: any) => it.id != null) - .map((it: any) => ({ label: steamCandidateLabel(it), patches: { steam_appid: String(it.id) } })); + .map((it: any) => ({ label: steamCandidateLabel(it), detail: steamCandidateDetail(it), patches: { steam_appid: String(it.id) } })); } } catch (e) { deps.log(`game steam storesearch failed for "${query}": ${String(e)}`); diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index fabdc59..0d203c0 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -16,6 +16,7 @@ const ANILIST_MEDIA_FIELDS = ` id idMal title { romaji english } + format status chapters volumes @@ -169,6 +170,15 @@ function stripAniListHtml(html: string | null | undefined): string { .trim(); } +/** Story-role staff (author-equivalent) off an AniList `Media` payload -- shared by record-building + * (`buildMangaFromAniList`) and the ambiguous-resolve picker detail (`candidateAniListDetail`). */ +function aniListStoryAuthors(media: any): string[] { + return ((media?.staff?.edges ?? []) as any[]) + .filter(e => typeof e?.role === 'string' && e.role.includes('Story')) + .map(e => e?.node?.name?.full) + .filter(Boolean); +} + /** * Pure mapper: AniList `Media` payload (the unwrapped `data.Media` object) + prev frontmatter -> * canonical MangaRecord. Mirrors `buildManga`'s user-field preservation contract exactly; only @@ -192,10 +202,7 @@ export function buildMangaFromAniList(media: any, prev: Record): const malId = media.idMal != null ? String(media.idMal) : stripQuotes(prev['mal_id']); const anilistId = media.id != null ? String(media.id) : stripQuotes(prev['anilist_id']); - const authors: string[] = ((media.staff?.edges ?? []) as any[]) - .filter(e => typeof e?.role === 'string' && e.role.includes('Story')) - .map(e => e?.node?.name?.full) - .filter(Boolean); + const authors = aniListStoryAuthors(media); return { title: romaji, @@ -263,6 +270,28 @@ function candidateAniListLabel(m: any): string { return year ? `${title} (${year})` : title; } +/** AniList `format` enum ("ONE_SHOT") -> Title Case ("One Shot"), or '' when absent. */ +function aniListFormatLabel(format: unknown): string { + if (typeof format !== 'string' || !format) return ''; + return format + .toLowerCase() + .split('_') + .filter(Boolean) + .map(w => w[0].toUpperCase() + w.slice(1)) + .join(' '); +} + +/** Picker second line (AniList side): format · status · first-two story authors · anilist:{id} -- + * whichever pieces the candidate actually has (search results are sparser than a full Media fetch). */ +function candidateAniListDetail(m: any): string | undefined { + const format = aniListFormatLabel(m?.format); + const status = ANILIST_STATUS_MAP[m?.status as string] ?? ''; + const authors = aniListStoryAuthors(m).slice(0, 2).join(', '); + const id = m?.id != null ? `anilist:${m.id}` : ''; + const parts = [format, status, authors, id].filter(Boolean); + return parts.length ? parts.join(' · ') : undefined; +} + export function renderManga(r: MangaRecord, myNotes: string, customSections: CustomSection[] = []): string { const fm = [ '---', @@ -468,7 +497,7 @@ export const mangaSpec: MediaTypeSpec = { anilistCandidates = anilistResults.slice(0, 6).map(m => { const patch: Record = { anilist_id: String(m.id) }; if (m.idMal != null) patch.mal_id = String(m.idMal); - return { label: candidateAniListLabel(m), patches: patch }; + return { label: candidateAniListLabel(m), detail: candidateAniListDetail(m), patches: patch }; }); } diff --git a/packages/obsidian/src/library/types.ts b/packages/obsidian/src/library/types.ts index 5a1476f..c5087fd 100644 --- a/packages/obsidian/src/library/types.ts +++ b/packages/obsidian/src/library/types.ts @@ -11,6 +11,7 @@ export interface LibraryNoteCtx { /** One candidate offered to the user when resolve() can't pick a unique match on its own. */ export interface ResolveCandidate { label: string; // human-readable (title + year/author/publisher/etc, whatever the spec already logs) + detail?: string; // optional second line for the picker (format/status/publisher/id, etc) -- muted, smaller text patches: Record; // full id-field patch for this candidate (e.g. anilist_id + mal_id) } diff --git a/packages/obsidian/src/styles.css b/packages/obsidian/src/styles.css index 3544a5f..5269684 100644 --- a/packages/obsidian/src/styles.css +++ b/packages/obsidian/src/styles.css @@ -325,3 +325,8 @@ small.media-db-plugin-list-text { .media-db-plugin-hidden { display: none; } + +.media-db-sync-candidate-detail { + opacity: 0.7; + font-size: 0.85em; +} diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index 6c6cfaf..60bae56 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -326,8 +326,25 @@ describe('bookSpec.resolve', () => { const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); expect(result).toEqual({ candidates: [ - { label: 'Foo', patches: { olid: 'OL1W' } }, - { label: 'Bar', patches: { olid: 'OL2W' } }, + { label: 'Foo', detail: 'OL1W', patches: { olid: 'OL1W' } }, + { label: 'Bar', detail: 'OL2W', patches: { olid: 'OL2W' } }, + ], + }); + }); + test('ambiguous candidate detail: first author · first-publish year · page count · olid', async () => { + const deps = makeDeps({ + http: async () => ({ + docs: [ + { key: '/works/OL1W', title: 'Foo', author_name: ['Jane Doe', 'John Roe'], first_publish_year: 1990, number_of_pages_median: 250 }, + { key: '/works/OL2W', title: 'Bar' }, // sparse -- only olid survives + ], + }), + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toEqual({ + candidates: [ + { label: 'Foo (Jane Doe, John Roe, 1990)', detail: 'Jane Doe · 1990 · 250p · OL1W', patches: { olid: 'OL1W' } }, + { label: 'Bar', detail: 'OL2W', patches: { olid: 'OL2W' } }, ], }); }); diff --git a/tests/library-candidate-picker-modal.test.ts b/tests/library-candidate-picker-modal.test.ts index 2546bf7..7f5f5a1 100644 --- a/tests/library-candidate-picker-modal.test.ts +++ b/tests/library-candidate-picker-modal.test.ts @@ -6,10 +6,27 @@ const CANDIDATES = [ { label: 'Bar (2021)', patches: { mal_id: '2' } }, ]; +const CANDIDATES_WITH_DETAIL = [ + { label: 'Foo (2020)', detail: 'Manga · Publishing · Author One · anilist:1', patches: { mal_id: '1' } }, + { label: 'Bar (2021)', patches: { mal_id: '2' } }, // no detail -- must render as a single-line row +]; + function fakeApp(): any { return {}; } +/** Minimal Obsidian `HTMLElement.createDiv` stand-in: records every call's options instead of touching a real DOM. */ +function fakeEl(): { calls: unknown[]; createDiv: (o?: unknown) => unknown } { + const calls: unknown[] = []; + return { + calls, + createDiv(o?: unknown) { + calls.push(o); + return fakeEl(); + }, + }; +} + describe('CandidatePickerModal', () => { test('getItems: candidate labels followed by Skip then Never resolve', () => { const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); @@ -27,6 +44,44 @@ describe('CandidatePickerModal', () => { expect(modal.getItemText({ label: 'Foo (2020)', index: 0 })).toBe('Foo (2020)'); }); + test('getItems: candidate detail threaded through, Skip/Never rows have no detail', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL); + const items = modal.getItems(); + expect(items).toEqual([ + { label: 'Foo (2020)', detail: 'Manga · Publishing · Author One · anilist:1', index: 0 }, + { label: 'Bar (2021)', detail: undefined, index: 1 }, + { label: 'Skip', index: null }, + { label: 'Never resolve (mark no_resolve)', index: 'never' }, + ]); + }); + + test('renderSuggestion: candidate with a detail -> label div + muted detail div', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL); + const item = modal.getItems()[0]; + const el = fakeEl(); + modal.renderSuggestion({ item, match: { score: 0, matches: [] } } as any, el as unknown as HTMLElement); + expect(el.calls).toEqual([{ text: 'Foo (2020)' }, { text: 'Manga · Publishing · Author One · anilist:1', cls: 'media-db-sync-candidate-detail' }]); + }); + + test('renderSuggestion: candidate with no detail -> label div only', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL); + const item = modal.getItems()[1]; + const el = fakeEl(); + modal.renderSuggestion({ item, match: { score: 0, matches: [] } } as any, el as unknown as HTMLElement); + expect(el.calls).toEqual([{ text: 'Bar (2021)' }]); + }); + + test('renderSuggestion: Skip/Never rows -> label div only, no detail row', () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES_WITH_DETAIL); + const items = modal.getItems(); + const skipEl = fakeEl(); + modal.renderSuggestion({ item: items[2], match: { score: 0, matches: [] } } as any, skipEl as unknown as HTMLElement); + expect(skipEl.calls).toEqual([{ text: 'Skip' }]); + const neverEl = fakeEl(); + modal.renderSuggestion({ item: items[3], match: { score: 0, matches: [] } } as any, neverEl as unknown as HTMLElement); + expect(neverEl.calls).toEqual([{ text: 'Never resolve (mark no_resolve)' }]); + }); + test('onChooseItem(candidate) -> pick() resolves to that candidate index', async () => { const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); const result = modal.pick(); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index 4b496af..7668936 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -291,8 +291,27 @@ describe('comicSpec.resolve', () => { const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); expect(result).toEqual({ candidates: [ - { label: 'Batman', patches: { comicvine_id: '1' } }, - { label: 'Batman Beyond', patches: { comicvine_id: '2' } }, + { label: 'Batman', detail: 'cv:1', patches: { comicvine_id: '1' } }, + { label: 'Batman Beyond', detail: 'cv:2', patches: { comicvine_id: '2' } }, + ], + }); + }); + + test('ambiguous candidate detail: publisher · issue count · start year · cv:{id}', async () => { + const deps = makeDeps({ + http: async () => ({ + results: [ + { id: 1, name: 'Batman', publisher: { name: 'DC Comics' }, count_of_issues: 85, start_year: 2016 }, + { id: 2, name: 'Batman Beyond' }, // sparse -- only cv:id survives + ], + }), + getKey: () => 'cvkey', + }); + const result = await comicSpec.resolve(ctxFor({ title: 'Absolute Batman' }), deps); + expect(result).toEqual({ + candidates: [ + { label: 'Batman (DC Comics, 2016)', detail: 'DC Comics · 85 issues · start 2016 · cv:1', patches: { comicvine_id: '1' } }, + { label: 'Batman Beyond', detail: 'cv:2', patches: { comicvine_id: '2' } }, ], }); }); diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index 82925a7..64e327e 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -329,8 +329,8 @@ describe('gameSpec.resolve', () => { const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); expect(result).toEqual({ candidates: [ - { label: 'Foo', patches: { steam_appid: '1' } }, - { label: 'Bar', patches: { steam_appid: '2' } }, + { label: 'Foo', detail: 'Steam appid 1', patches: { steam_appid: '1' } }, + { label: 'Bar', detail: 'Steam appid 2', patches: { steam_appid: '2' } }, ], }); expect(deps.logCalls.some(m => m.includes('RAWG'))).toBe(true); diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index 46e4820..c495e75 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -873,8 +873,45 @@ describe('mangaSpec.resolve — AniList primary', () => { const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); expect(result).toEqual({ candidates: [ - { label: 'Foo (2020)', patches: { anilist_id: '1', mal_id: '11' } }, - { label: 'Bar (2021)', patches: { anilist_id: '2', mal_id: '22' } }, + { label: 'Foo (2020)', detail: 'anilist:1', patches: { anilist_id: '1', mal_id: '11' } }, + { label: 'Bar (2021)', detail: 'anilist:2', patches: { anilist_id: '2', mal_id: '22' } }, + ], + }); + }); + + test('AniList ambiguous candidate detail: format · status · first-two story authors · anilist:{id}', async () => { + const deps = makeDeps({ + http: async () => ({ data: [] }), + httpPostJson: async () => + anilistPage([ + { + id: 1, + idMal: 11, + title: { romaji: 'Foo', english: '' }, + startDate: { year: 2020 }, + format: 'ONE_SHOT', + status: 'RELEASING', + staff: { + edges: [ + { role: 'Story & Art', node: { name: { full: 'Author One' } } }, + { role: 'Story', node: { name: { full: 'Author Two' } } }, + { role: 'Story', node: { name: { full: 'Author Three' } } }, // 3rd Story credit -- dropped, detail caps at 2 + { role: 'Illustration', node: { name: { full: 'Illustrator Only' } } }, // non-Story role -- excluded + ], + }, + }, + { id: 2, idMal: 22, title: { romaji: 'Bar', english: '' }, startDate: { year: 2021 } }, // sparse -- only id survives + ]), + }); + const result = await mangaSpec.resolve(ctxFor({ title: 'Chainsaw Man' }, ''), deps); + expect(result).toEqual({ + candidates: [ + { + label: 'Foo (2020)', + detail: 'One Shot · Publishing · Author One, Author Two · anilist:1', + patches: { anilist_id: '1', mal_id: '11' }, + }, + { label: 'Bar (2021)', detail: 'anilist:2', patches: { anilist_id: '2', mal_id: '22' } }, ], }); }); From 83bf8323e2ff721567b1d215c7eb0a4335b0d340 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 19:11:53 +0800 Subject: [PATCH 48/53] =?UTF-8?q?fix(library):=20picker=20choice=20lost=20?= =?UTF-8?q?=E2=80=94=20Obsidian=20close()=20precedes=20onChooseItem,=20def?= =?UTF-8?q?er=20dismiss-settle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/obsidian/src/library/CandidatePickerModal.ts | 5 ++++- tests/library-candidate-picker-modal.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/obsidian/src/library/CandidatePickerModal.ts b/packages/obsidian/src/library/CandidatePickerModal.ts index 0daab33..063d195 100644 --- a/packages/obsidian/src/library/CandidatePickerModal.ts +++ b/packages/obsidian/src/library/CandidatePickerModal.ts @@ -55,7 +55,10 @@ export class CandidatePickerModal extends FuzzySuggestModal { } onClose(): void { - this.settle(null); // dismissed without choosing -> treat as skip + // Obsidian's selectSuggestion() calls close() BEFORE onChooseItem(), so settling here + // synchronously would discard a real choice. Defer one tick: a choice made in the same + // tick settles first and this becomes a no-op; a genuine dismissal settles as skip. + window.setTimeout(() => this.settle(null), 0); this.contentEl.empty(); } diff --git a/tests/library-candidate-picker-modal.test.ts b/tests/library-candidate-picker-modal.test.ts index 7f5f5a1..b59988a 100644 --- a/tests/library-candidate-picker-modal.test.ts +++ b/tests/library-candidate-picker-modal.test.ts @@ -117,4 +117,13 @@ describe('CandidatePickerModal', () => { modal.onClose(); // Obsidian calls onClose() after a choice too -- must not clobber the resolved value expect(await result).toBe(0); }); + + test('REAL Obsidian order — close() fires BEFORE onChooseItem: choice still wins over dismiss-skip', async () => { + const modal = new CandidatePickerModal(fakeApp(), 'Some Note.md', CANDIDATES); + const result = modal.pick(); + // selectSuggestion() in Obsidian calls close() (→ onClose) first, THEN onChooseItem + modal.onClose(); + modal.onChooseItem({ label: 'Foo (2020)', index: 0 }, {} as MouseEvent); + expect(await result).toBe(0); + }); }); From eb6b3d25d00eeefb6f8190c036d31f7426908946 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 19:12:23 +0800 Subject: [PATCH 49/53] fix(library): globalThis setTimeout in modal (test env has no window) --- packages/obsidian/src/library/CandidatePickerModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/obsidian/src/library/CandidatePickerModal.ts b/packages/obsidian/src/library/CandidatePickerModal.ts index 063d195..52a9c89 100644 --- a/packages/obsidian/src/library/CandidatePickerModal.ts +++ b/packages/obsidian/src/library/CandidatePickerModal.ts @@ -58,7 +58,7 @@ export class CandidatePickerModal extends FuzzySuggestModal { // Obsidian's selectSuggestion() calls close() BEFORE onChooseItem(), so settling here // synchronously would discard a real choice. Defer one tick: a choice made in the same // tick settles first and this becomes a no-op; a genuine dismissal settles as skip. - window.setTimeout(() => this.settle(null), 0); + setTimeout(() => this.settle(null), 0); this.contentEl.empty(); } From 75db364a1d12c865d67a3865efc9902b40e5f486 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 20:35:36 +0800 Subject: [PATCH 50/53] fix(library): exclude no_resolve notes from sync no-id count --- packages/obsidian/src/library/LibraryEngine.ts | 7 ++++++- tests/library-engine.test.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/obsidian/src/library/LibraryEngine.ts b/packages/obsidian/src/library/LibraryEngine.ts index 8488d8e..dc98f73 100644 --- a/packages/obsidian/src/library/LibraryEngine.ts +++ b/packages/obsidian/src/library/LibraryEngine.ts @@ -62,7 +62,12 @@ export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngine const filename = filenameOf(note.path); if (isSkippableNote(filename, frontmatter, spec.itemType)) continue; if (!spec.hasId(frontmatter)) { - report.skippedNoId++; + // no_resolve opt-outs are deliberate manual notes — keep them out of the actionable no-id count + if (hasNoResolveFlag(frontmatter)) { + deps.log(`no_resolve flag set, skipping sync: ${note.path}`); + } else { + report.skippedNoId++; + } continue; } if (!opts.full && !spec.isActive(frontmatter)) { diff --git a/tests/library-engine.test.ts b/tests/library-engine.test.ts index dd4f566..8b8150a 100644 --- a/tests/library-engine.test.ts +++ b/tests/library-engine.test.ts @@ -439,4 +439,18 @@ describe('libraryFolderResolve: no_resolve flag', () => { expect(report.skippedNoResolve).toBe(0); expect(report.resolved).toEqual(['A.md']); }); + + test('SYNC: no_resolve id-less note excluded from skippedNoId (silent log skip); plain id-less note still counted', async () => { + const { spec } = makeFakeSpec({}); + const flagged = NO_ID_NOTE.replace('fake_id: ', 'fake_id: \nno_resolve: true'); + const logs: string[] = []; + const { deps } = makeDeps([ + { path: 'Flagged.md', content: flagged }, + { path: 'Plain.md', content: NO_ID_NOTE }, + ]); + deps.log = (m: string) => logs.push(m); + const report = await libraryFolderSync(spec, deps, { full: true }); + expect(report.skippedNoId).toBe(1); + expect(logs.some(l => l.includes('no_resolve flag set, skipping sync: Flagged.md'))).toBe(true); + }); }); From 8fb16b3220107f3c45d7c82292a06f5b92841ccf Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 5 Aug 2026 20:55:42 +0800 Subject: [PATCH 51/53] feat(library): local canonical conversion for id-less notes --- .../obsidian/src/library/LibraryController.ts | 3 +- .../obsidian/src/library/LibraryEngine.ts | 15 ++- packages/obsidian/src/library/book.ts | 40 +++++++ packages/obsidian/src/library/comic.ts | 40 +++++++ packages/obsidian/src/library/game.ts | 42 ++++++++ packages/obsidian/src/library/manga.ts | 51 +++++++++ packages/obsidian/src/library/types.ts | 5 + tests/library-book.test.ts | 89 ++++++++++++++- tests/library-comic.test.ts | 88 ++++++++++++++- tests/library-controller.test.ts | 2 + tests/library-engine.test.ts | 70 +++++++++++- tests/library-game.test.ts | 90 +++++++++++++++- tests/library-manga.test.ts | 102 +++++++++++++++++- 13 files changed, 630 insertions(+), 7 deletions(-) diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts index 760391c..69e785c 100644 --- a/packages/obsidian/src/library/LibraryController.ts +++ b/packages/obsidian/src/library/LibraryController.ts @@ -32,7 +32,7 @@ const KEY_LABEL: Record<'rawg' | 'comicvine', string> = { }; function emptyReport(): LibraryReport { - return { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, flipped: [], errors: [] }; + return { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, convertedLocal: 0, flipped: [], errors: [] }; } function emptyResolveReport(): LibraryResolveReport { @@ -176,6 +176,7 @@ export class LibraryController { * success just because skippedNoData/errors were left out of the message. */ private buildSyncSummary(spec: MediaTypeSpec, mode: string, report: LibraryReport): string { let msg = `Library ${mode}sync (${spec.typeName}): ${report.scanned} scanned, ${report.synced} ok, ${report.written} updated, ${report.flipped.length} flipped`; + if (report.convertedLocal) msg += `, ${report.convertedLocal} converted (local)`; if (report.skippedStatic) msg += `, ${report.skippedStatic} static`; if (report.skippedNoId) msg += `, ${report.skippedNoId} no-id`; if (report.skippedNoData) msg += `, ${report.skippedNoData} no-data (see console)`; diff --git a/packages/obsidian/src/library/LibraryEngine.ts b/packages/obsidian/src/library/LibraryEngine.ts index dc98f73..adee18b 100644 --- a/packages/obsidian/src/library/LibraryEngine.ts +++ b/packages/obsidian/src/library/LibraryEngine.ts @@ -19,6 +19,7 @@ export interface LibraryReport { skippedNoId: number; skippedStatic: number; skippedNoData: number; + convertedLocal: number; flipped: string[]; errors: { path: string; error: string }[]; } @@ -51,7 +52,7 @@ function isSkippableNote(filename: string, fm: Record, itemType: } export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngineDeps, opts: { full?: boolean; dryRun?: boolean } = {}): Promise { - const report: LibraryReport = { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, flipped: [], errors: [] }; + const report: LibraryReport = { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, convertedLocal: 0, flipped: [], errors: [] }; const notes = await deps.listNotes(); for (const note of notes) { report.scanned++; @@ -68,6 +69,18 @@ export async function libraryFolderSync(spec: MediaTypeSpec, deps: LibraryEngine } else { report.skippedNoId++; } + // Id-less notes never reach spec.sync() (no API id to enrich from), but must still + // stop being permanently invisible to type-filtered Bases queries -- convertLocal + // builds canonical `_item` content purely from what the note already has, no + // network. One-time in practice: re-parsing the converted note yields byte-identical + // output next pass, so diff-on-write means it's never rewritten (or re-counted) again. + const localCtx: LibraryNoteCtx = { frontmatter, body, filename }; + const converted = spec.convertLocal(localCtx); + if (converted !== content) { + report.convertedLocal++; + if (!opts.dryRun) await deps.writeNote(note.path, converted); + deps.log(`${opts.dryRun ? '[dry] ' : ''}converted (local) ${note.path}`); + } continue; } if (!opts.full && !spec.isActive(frontmatter)) { diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts index 7b2f012..3c88eb3 100644 --- a/packages/obsidian/src/library/book.ts +++ b/packages/obsidian/src/library/book.ts @@ -58,6 +58,40 @@ export function buildBook(doc: any, prev: Record): BookRecord { }; } +/** + * Pure mapper: prev frontmatter only (no API payload) -> canonical BookRecord, for id-less + * notes that can never be resolved (or haven't been resolved yet) but still need a canonical + * `book_item` shape so they aren't permanently invisible to type-filtered Bases queries. + * User-managed fields go through the same derive helpers as buildBook (including the legacy + * singular `author` skeleton fallback); everything API-derived (year, pages, genre, poster, + * Open Library url) is empty/null. `title` falls back to the filename (minus `.md`) when the + * note has no title field at all. + */ +export function buildBookLocal(prev: Record, filename: string): BookRecord { + const title = stripQuotes(prev['title']) || filename.replace(/\.md$/, ''); + const { rating, ratingStars } = deriveRating(prev); + const readStatus = deriveReadStatus(prev); + // skeleton conversion: legacy singular `author` field -- mirrors deriveAuthors's own fallback + const legacyAuthor = stripQuotes(prev['author']); + const authors = legacyAuthor ? [legacyAuthor] : []; + const olid = stripQuotes(prev['olid']); + + return { + title, + readStatus, + rating, + ratingStars, + authors, + year: null, + pages: null, + genre: [], + olid, + isbn: stripQuotes(prev['isbn']), + poster: null, + url: olid ? `${OPENLIBRARY_BASE}/works/${olid}` : '', + }; +} + /** Carries the legacy goodreads search link forward as a `## Links` entry across every re-render. */ function resolveGoodreadsLink(prev: Record, body: string): string { const linksMatch = /##\s*Links\s*\n([\s\S]*?)(?=\n##\s|$)/.exec(body ?? ''); @@ -226,4 +260,10 @@ export const bookSpec: MediaTypeSpec = { const content = renderBook(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body), goodreadsUrl); return { content, flipped: false }; // static spec -- no automation, never flips }, + + convertLocal(ctx: LibraryNoteCtx): string { + const record = buildBookLocal(ctx.frontmatter, ctx.filename); + const goodreadsUrl = resolveGoodreadsLink(ctx.frontmatter, ctx.body); + return renderBook(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body), goodreadsUrl); + }, }; diff --git a/packages/obsidian/src/library/comic.ts b/packages/obsidian/src/library/comic.ts index 95bc258..ff02ee0 100644 --- a/packages/obsidian/src/library/comic.ts +++ b/packages/obsidian/src/library/comic.ts @@ -92,6 +92,41 @@ export function buildComic(cv: any, prev: Record): ComicRecord { }; } +/** + * Pure mapper: prev frontmatter only (no API payload) -> canonical ComicRecord, for id-less + * notes that can never be resolved (or haven't been resolved yet) but still need a canonical + * `comic_item` shape so they aren't permanently invisible to type-filtered Bases queries. + * User-managed fields go through the same derive helpers as buildComic (including deriveStatus's + * 'Ongoing' default); everything API-derived (issues, publisher, people, start year, poster, + * Comic Vine url, description) is empty/null. `title` falls back to the filename (minus `.md`) + * when the note has no title field at all. + */ +export function buildComicLocal(prev: Record, filename: string): ComicRecord { + const title = stripQuotes(prev['title']) || filename.replace(/\.md$/, ''); + const { rating, ratingStars } = deriveRating(prev); + const readStatus = deriveReadStatus(prev); + const lastReadIssue = stripQuotes(prev['last_read_issue']); + const comicvineId = stripQuotes(prev['comicvine_id']); + + return { + title, + readStatus, + rating, + ratingStars, + lastReadIssue, + latestIssue: parseNumOrNull(prev['latest_issue']), + issues: null, + status: deriveStatus(prev), + publisher: '', + people: [], + startYear: '', + comicvineId, + poster: null, + url: '', + description: '', + }; +} + export function renderComic(r: ComicRecord, myNotes: string, customSections: CustomSection[] = []): string { const fm = [ '---', @@ -296,4 +331,9 @@ export const comicSpec: MediaTypeSpec = { const content = renderComic(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); return { content, flipped }; }, + + convertLocal(ctx: LibraryNoteCtx): string { + const record = buildComicLocal(ctx.frontmatter, ctx.filename); + return renderComic(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); + }, }; diff --git a/packages/obsidian/src/library/game.ts b/packages/obsidian/src/library/game.ts index a263083..489537f 100644 --- a/packages/obsidian/src/library/game.ts +++ b/packages/obsidian/src/library/game.ts @@ -114,6 +114,43 @@ export function buildGame(source: 'steam' | 'rawg', data: any, prev: Record canonical GameRecord, for id-less + * notes that can never be resolved (or haven't been resolved yet) but still need a canonical + * `game_item` shape so they aren't permanently invisible to type-filtered Bases queries. + * User-managed fields go through the same derive helpers as buildGame; everything API-derived + * (developer, publisher, platforms, genre, release date, metacritic, poster, description) is + * empty/null. `url` carries forward whatever the note already had (e.g. a Steam store link from + * the vault skeleton) untouched -- no synthetic link is built without a resolved id. `title` + * falls back to the filename (minus `.md`) when the note has no title field at all. + */ +export function buildGameLocal(prev: Record, filename: string): GameRecord { + const title = stripQuotes(prev['title']) || filename.replace(/\.md$/, ''); + const { rating, ratingStars } = deriveRating(prev); + const playStatus = derivePlayStatus(prev); + const steamAppid = stripQuotes(prev['steam_appid']); + const rawgId = stripQuotes(prev['rawg_id']); + const url = stripQuotes(prev['url']); + + return { + title, + playStatus, + rating, + ratingStars, + developer: [], + publisher: [], + platforms: [], + genre: [], + releaseDate: '', + metacritic: null, + steamAppid, + rawgId, + poster: null, + url, + description: '', + }; +} + export function renderGame(r: GameRecord, myNotes: string, customSections: CustomSection[] = []): string { const fm = [ '---', @@ -315,4 +352,9 @@ export const gameSpec: MediaTypeSpec = { return null; // steam_appid set, success:false, no rawg fallback available }, + + convertLocal(ctx: LibraryNoteCtx): string { + const record = buildGameLocal(ctx.frontmatter, ctx.filename); + return renderGame(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); + }, }; diff --git a/packages/obsidian/src/library/manga.ts b/packages/obsidian/src/library/manga.ts index 0d203c0..103e7f4 100644 --- a/packages/obsidian/src/library/manga.ts +++ b/packages/obsidian/src/library/manga.ts @@ -145,6 +145,52 @@ export function buildManga(jikan: any, prev: Record): MangaRecor }; } +/** + * Pure mapper: prev frontmatter only (no API payload) -> canonical MangaRecord, for id-less + * notes that can never be resolved (or haven't been resolved yet) but still need a canonical + * `manga_item` shape so they aren't permanently invisible to type-filtered Bases queries. + * User-managed fields go through the same derive helpers as buildManga/buildMangaFromAniList; + * everything API-derived (chapters, volumes, status, authors, genre, score, dates, poster, + * synopsis) is empty/null -- there's no source to pull it from. `title` falls back to the + * filename (minus `.md`) when the note has no title field at all. + */ +export function buildMangaLocal(prev: Record, filename: string): MangaRecord { + const title = stripQuotes(prev['title']) || filename.replace(/\.md$/, ''); + const { rating, ratingStars } = deriveRating(prev); + const readStatus = deriveReadStatus(prev); + const lastReadChapter = stripQuotes(prev['last_read_chapter']); + const rss = stripNullSentinel(stripQuotes(prev['rss'])); + const mangadexId = stripQuotes(prev['mangadex_id']); + const malId = stripQuotes(prev['mal_id']); + const anilistId = stripQuotes(prev['anilist_id']); + + return { + title, + engName: '', + readStatus, + rating, + ratingStars, + lastReadChapter, + latestChapter: parseNumOrNull(prev['latest_chapter']), + lastChapterDate: stripNullSentinel(stripQuotes(prev['last_chapter_date'])), + chapters: null, + volumes: null, + status: '', + authors: [], + genre: [], + score: null, + publishedFrom: null, + publishedTo: null, + malId, + anilistId, + mangadexId, + rss, + poster: null, + url: malId ? `https://myanimelist.net/manga/${malId}` : '', + synopsis: '', + }; +} + /** AniList `{year,month,day}` date object -> ISO `YYYY-MM-DD`, or null when any part is missing * (AniList leaves in-progress end dates as all-null rather than omitting the object). */ function aniListDate(d: { year?: number | null; month?: number | null; day?: number | null } | null | undefined): string | null { @@ -592,4 +638,9 @@ export const mangaSpec: MediaTypeSpec = { const content = renderManga(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); return { content, flipped }; }, + + convertLocal(ctx: LibraryNoteCtx): string { + const record = buildMangaLocal(ctx.frontmatter, ctx.filename); + return renderManga(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body)); + }, }; diff --git a/packages/obsidian/src/library/types.ts b/packages/obsidian/src/library/types.ts index c5087fd..793058e 100644 --- a/packages/obsidian/src/library/types.ts +++ b/packages/obsidian/src/library/types.ts @@ -28,6 +28,11 @@ export interface MediaTypeSpec { isActive(fm: Record): boolean; resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise; // null = no match/error sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null>; // full new note content; null = skip + // Network-free canonical conversion for id-less notes: builds full canonical note content + // purely from prev frontmatter + body (no API calls), so stock-skeleton/unresolvable notes + // still get a `_item` shape (and so become visible to Bases) instead of staying invisible + // forever. Pure + idempotent -- same prev in, same content out, every time. + convertLocal(ctx: LibraryNoteCtx): string; } export interface SpecDeps { diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts index 60bae56..4d290bc 100644 --- a/tests/library-book.test.ts +++ b/tests/library-book.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { buildBook, renderBook, bookSpec, type BookRecord } from 'packages/obsidian/src/library/book'; +import { buildBook, buildBookLocal, renderBook, bookSpec, type BookRecord } from 'packages/obsidian/src/library/book'; import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; import olFixture from 'tests/fixtures/openlibrary-search.json'; @@ -421,3 +421,90 @@ describe('bookSpec.resolve — author hint (stock `author` / canonical `authors` expect(result).toEqual({ patches: { olid: 'OL1168083W' } }); }); }); + +describe('buildBookLocal: pure prev-only mapper (no API payload)', () => { + test('empty prev + filename fallback -> title from filename, everything else empty/null', () => { + const r = buildBookLocal({}, 'Nineteen Eighty-Four.md'); + expect(r.title).toBe('Nineteen Eighty-Four'); + expect(r.readStatus).toBe('Unread'); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + expect(r.authors).toEqual([]); + expect(r.year).toBeNull(); + expect(r.pages).toBeNull(); + expect(r.genre).toEqual([]); + expect(r.olid).toBe(''); + expect(r.isbn).toBe(''); + expect(r.poster).toBeNull(); + expect(r.url).toBe(''); + }); + + test('prev title wins over filename', () => { + expect(buildBookLocal({ title: '1984' }, 'Nineteen Eighty-Four.md').title).toBe('1984'); + }); + + test('skeleton legacy fields (read/personalRating/author) converted via existing derive helpers', () => { + const r = buildBookLocal({ read: 'true', personalRating: '4', author: 'George Orwell' }, 'X.md'); + expect(r.readStatus).toBe('Read'); + expect(r.rating).toBe('4'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + expect(r.authors).toEqual(['George Orwell']); + }); + + test('carries whatever olid/isbn prev already has', () => { + const r = buildBookLocal({ olid: 'OL1168083W', isbn: '9780451524935' }, 'X.md'); + expect(r.olid).toBe('OL1168083W'); + expect(r.isbn).toBe('9780451524935'); + expect(r.url).toBe('https://openlibrary.org/works/OL1168083W'); + }); +}); + +describe('bookSpec.convertLocal: no-network canonical conversion for id-less notes', () => { + test('stock-skeleton note -> canonical book_item shape, title falls back to filename', () => { + const fm = { type: 'book', read: 'true', personalRating: '3', author: 'George Orwell' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\nsome notes', filename: '1984.md' }; + const content = bookSpec.convertLocal(ctx); + expect(content).toContain('type: book_item'); + expect(content).toContain('title: 1984'); + expect(content).toContain('read_status: Read'); + expect(content).toContain('rating: 3'); + expect(content).toContain('## My Notes'); + expect(content).toContain('some notes'); + }); + + test('legacy plain `url` field carried forward as a Goodreads Links entry', () => { + const fm = { type: 'book', url: 'https://www.goodreads.com/book/show/5470.1984' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: '1984.md' }; + const content = bookSpec.convertLocal(ctx); + expect(content).toContain('[Goodreads](https://www.goodreads.com/book/show/5470.1984)'); + }); + + test('preserves custom sections through conversion', () => { + const ctx: LibraryNoteCtx = { + frontmatter: {}, + body: '## Quotes\n\nsome quote\n\n## My Notes\n\nkeep me', + filename: 'X.md', + }; + const content = bookSpec.convertLocal(ctx); + expect(content).toContain('## Quotes'); + expect(content).toContain('some quote'); + expect(content).toContain('keep me'); + }); + + test('round-trip idempotence: re-running convertLocal on its own output yields byte-identical content', () => { + const fm = { type: 'book', read: 'false', personalRating: '', url: 'https://www.goodreads.com/book/show/5470.1984' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: '1984.md' }; + const once = bookSpec.convertLocal(ctx); + const { frontmatter: fm2, body: body2 } = (() => { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(once)!; + const f: Record = {}; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) f[mm[1]] = mm[2].trim(); + } + return { frontmatter: f, body: m[2] }; + })(); + const twice = bookSpec.convertLocal({ frontmatter: fm2, body: body2, filename: '1984.md' }); + expect(twice).toBe(once); + }); +}); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts index 7668936..4c03a3e 100644 --- a/tests/library-comic.test.ts +++ b/tests/library-comic.test.ts @@ -1,7 +1,7 @@ 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 { buildComic, buildComicLocal, 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'; @@ -545,3 +545,89 @@ describe('comicSpec.sync — seed pass (I3): no stored latest_issue never flips expect(deps.notifyCalls).toEqual(['«Absolute Batman» issue 11 out']); }); }); + +describe('buildComicLocal: pure prev-only mapper (no API payload)', () => { + test('empty prev + filename fallback -> title from filename, everything else empty/null', () => { + const r = buildComicLocal({}, 'Ghostblade.md'); + expect(r.title).toBe('Ghostblade'); + expect(r.readStatus).toBe('Unread'); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + expect(r.lastReadIssue).toBe(''); + expect(r.latestIssue).toBeNull(); + expect(r.issues).toBeNull(); + expect(r.status).toBe('Ongoing'); + expect(r.publisher).toBe(''); + expect(r.people).toEqual([]); + expect(r.startYear).toBe(''); + expect(r.comicvineId).toBe(''); + expect(r.poster).toBeNull(); + expect(r.url).toBe(''); + expect(r.description).toBe(''); + }); + + test('prev title wins over filename', () => { + expect(buildComicLocal({ title: 'Ghostblade' }, 'X.md').title).toBe('Ghostblade'); + }); + + test('skeleton legacy fields (read/personalRating) converted via existing derive helpers', () => { + const r = buildComicLocal({ read: 'true', personalRating: '5' }, 'X.md'); + expect(r.readStatus).toBe('Read'); + expect(r.rating).toBe('5'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️⭐️'); + }); + + test('existing status carried forward, not overwritten to the Ongoing default', () => { + expect(buildComicLocal({ status: 'Finished' }, 'X.md').status).toBe('Finished'); + }); + + test('carries whatever comicvine_id/last_read_issue/latest_issue prev already has', () => { + const r = buildComicLocal({ comicvine_id: '195824', last_read_issue: '5', latest_issue: '10' }, 'X.md'); + expect(r.comicvineId).toBe('195824'); + expect(r.lastReadIssue).toBe('5'); + expect(r.latestIssue).toBe(10); + }); +}); + +describe('comicSpec.convertLocal: no-network canonical conversion for id-less notes', () => { + test('stock-skeleton note -> canonical comic_item shape, title falls back to filename', () => { + const fm = { type: 'comicManga', read: 'true', personalRating: '3' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\nsome notes', filename: 'Ghostblade.md' }; + const content = comicSpec.convertLocal(ctx); + expect(content).toContain('type: comic_item'); + expect(content).toContain('title: Ghostblade'); + expect(content).toContain('read_status: Read'); + expect(content).toContain('rating: 3'); + expect(content).toContain('## My Notes'); + expect(content).toContain('some notes'); + }); + + test('preserves custom sections through conversion', () => { + const ctx: LibraryNoteCtx = { + frontmatter: {}, + body: '## Story Arcs\n\narc 1\n\n## My Notes\n\nkeep me', + filename: 'X.md', + }; + const content = comicSpec.convertLocal(ctx); + expect(content).toContain('## Story Arcs'); + expect(content).toContain('arc 1'); + expect(content).toContain('keep me'); + }); + + test('round-trip idempotence: re-running convertLocal on its own output yields byte-identical content', () => { + const fm = { type: 'comicManga', read: 'false', personalRating: '' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: 'Ghostblade.md' }; + const once = comicSpec.convertLocal(ctx); + const { frontmatter: fm2, body: body2 } = (() => { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(once)!; + const f: Record = {}; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) f[mm[1]] = mm[2].trim(); + } + return { frontmatter: f, body: m[2] }; + })(); + const twice = comicSpec.convertLocal({ frontmatter: fm2, body: body2, filename: 'Ghostblade.md' }); + expect(twice).toBe(once); + }); +}); diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts index dba23fe..9827b25 100644 --- a/tests/library-controller.test.ts +++ b/tests/library-controller.test.ts @@ -449,6 +449,7 @@ describe('sync summary transparency', () => { isActive: () => true, resolve: async () => null, sync: async () => null, + convertLocal: () => '', }; const report = await c.syncType(noDataSpec, false); @@ -478,6 +479,7 @@ describe('resolveType: interactive candidate picker (needsChoice)', () => { isActive: () => true, resolve: async () => ({ candidates: CANDIDATES }), sync: async () => null, + convertLocal: () => '', }; function makeCandidateDeps(writes: { path: string; content: string }[]): LibraryEngineDeps { diff --git a/tests/library-engine.test.ts b/tests/library-engine.test.ts index 8b8150a..83feb02 100644 --- a/tests/library-engine.test.ts +++ b/tests/library-engine.test.ts @@ -15,12 +15,14 @@ interface FakeSpecOptions { isActive?(fm: Record): boolean; sync?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType; resolve?(ctx: LibraryNoteCtx, deps: SpecDeps): ReturnType; + convertLocal?(ctx: LibraryNoteCtx): string; throttleMs?: number; } function makeFakeSpec(opts: FakeSpecOptions = {}) { const syncCalls: LibraryNoteCtx[] = []; const resolveCalls: LibraryNoteCtx[] = []; + const convertLocalCalls: LibraryNoteCtx[] = []; const spec: MediaTypeSpec = { typeName: 'manga', itemType: FAKE_ITEM_TYPE, @@ -38,8 +40,12 @@ function makeFakeSpec(opts: FakeSpecOptions = {}) { if (opts.sync) return await opts.sync(ctx, deps); return { content: renderFake(ctx.frontmatter, extractMyNotes(ctx.body)), flipped: false }; }, + convertLocal: ctx => { + convertLocalCalls.push(ctx); + return opts.convertLocal ? opts.convertLocal(ctx) : renderFake(ctx.frontmatter, extractMyNotes(ctx.body)); + }, }; - return { spec, syncCalls, resolveCalls }; + return { spec, syncCalls, resolveCalls, convertLocalCalls }; } const ACTIVE_NOTE = `--- @@ -111,6 +117,68 @@ describe('libraryFolderSync: tiering', () => { }); }); +describe('libraryFolderSync: local conversion (no-id notes)', () => { + test('unflagged no-id note: convertLocal differs from content -> written once, convertedLocal++, skippedNoId still counted, sync() never called', async () => { + const { spec, syncCalls } = makeFakeSpec({ convertLocal: () => 'CONVERTED' }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.convertedLocal).toBe(1); + expect(report.skippedNoId).toBe(1); + expect(writes.length).toBe(1); + expect(writes[0].content).toBe('CONVERTED'); + expect(syncCalls.length).toBe(0); + }); + + test('idempotence: second pass over already-converted content writes nothing, convertedLocal stays 0', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED' }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const first = await libraryFolderSync(spec, deps, {}); + expect(first.convertedLocal).toBe(1); + expect(writes.length).toBe(1); + + const second = makeDeps([{ path: 'A.md', content: writes[0].content }]); + const report2 = await libraryFolderSync(spec, second.deps, {}); + expect(second.writes.length).toBe(0); + expect(report2.convertedLocal).toBe(0); + }); + + test('convertLocal output identical to existing content -> no write, convertedLocal stays 0', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => NO_ID_NOTE }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.convertedLocal).toBe(0); + expect(writes.length).toBe(0); + }); + + test('no_resolve-flagged no-id note: still converted+written, but excluded from skippedNoId (no_resolve log line stays)', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED' }); + const flagged = NO_ID_NOTE.replace('fake_id: ', 'fake_id: \nno_resolve: true'); + const logs: string[] = []; + const { deps, writes } = makeDeps([{ path: 'A.md', content: flagged }]); + deps.log = (m: string) => logs.push(m); + const report = await libraryFolderSync(spec, deps, {}); + expect(report.convertedLocal).toBe(1); + expect(report.skippedNoId).toBe(0); + expect(writes.length).toBe(1); + expect(logs.some(l => l.includes('no_resolve flag set, skipping sync: A.md'))).toBe(true); + }); + + test('dryRun: convertedLocal counted but no writeNote call', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED' }); + const { deps, writes } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + const report = await libraryFolderSync(spec, deps, { dryRun: true }); + expect(report.convertedLocal).toBe(1); + expect(writes.length).toBe(0); + }); + + test('no-id note does not sleep spec.throttleMs (no network involved)', async () => { + const { spec } = makeFakeSpec({ convertLocal: () => 'CONVERTED', throttleMs: 777 }); + const { deps, slept } = makeDeps([{ path: 'A.md', content: NO_ID_NOTE }]); + await libraryFolderSync(spec, deps, {}); + expect(slept).not.toContain(777); + }); +}); + describe('libraryFolderSync: diff-on-write + dryRun', () => { test('diff-on-write: second pass on rendered output writes nothing', async () => { const { spec } = makeFakeSpec(); diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts index 64e327e..18d75b2 100644 --- a/tests/library-game.test.ts +++ b/tests/library-game.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { buildGame, renderGame, parseSteamDate, gameSpec, type GameRecord } from 'packages/obsidian/src/library/game'; +import { buildGame, buildGameLocal, renderGame, parseSteamDate, gameSpec, type GameRecord } from 'packages/obsidian/src/library/game'; import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; import steamFixture from 'tests/fixtures/steam-appdetails.json'; import rawgFixture from 'tests/fixtures/rawg-game.json'; @@ -540,3 +540,91 @@ describe('gameSpec.sync — rawg enrich', () => { expect(result).toBeNull(); }); }); + +describe('buildGameLocal: pure prev-only mapper (no API payload)', () => { + test('empty prev + filename fallback -> title from filename, everything else empty/null', () => { + const r = buildGameLocal({}, 'Avatar - Frontiers of Pandora.md'); + expect(r.title).toBe('Avatar - Frontiers of Pandora'); + expect(r.playStatus).toBe('Unplayed'); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + expect(r.developer).toEqual([]); + expect(r.publisher).toEqual([]); + expect(r.platforms).toEqual([]); + expect(r.genre).toEqual([]); + expect(r.releaseDate).toBe(''); + expect(r.metacritic).toBeNull(); + expect(r.steamAppid).toBe(''); + expect(r.rawgId).toBe(''); + expect(r.poster).toBeNull(); + expect(r.url).toBe(''); + expect(r.description).toBe(''); + }); + + test('prev title wins over filename', () => { + expect(buildGameLocal({ title: 'Avatar' }, 'X.md').title).toBe('Avatar'); + }); + + test('skeleton legacy `played` field converted via existing derive helper', () => { + expect(buildGameLocal({ played: 'true' }, 'X.md').playStatus).toBe('Played'); + expect(buildGameLocal({ played: 'false' }, 'X.md').playStatus).toBe('Unplayed'); + }); + + test('canonical `play_status` wins over legacy `played`', () => { + expect(buildGameLocal({ played: 'false', play_status: 'Playing' }, 'X.md').playStatus).toBe('Playing'); + }); + + test('carries whatever id fields prev already has (steam_appid, rawg_id)', () => { + const r = buildGameLocal({ steam_appid: '2379780', rawg_id: '4200' }, 'X.md'); + expect(r.steamAppid).toBe('2379780'); + expect(r.rawgId).toBe('4200'); + }); + + test('existing url carried forward untouched, no synthetic Steam/RAWG link built', () => { + const r = buildGameLocal({ url: 'https://store.steampowered.com/app/2379780/Avatar/' }, 'X.md'); + expect(r.url).toBe('https://store.steampowered.com/app/2379780/Avatar/'); + }); +}); + +describe('gameSpec.convertLocal: no-network canonical conversion for id-less notes', () => { + test('stock-skeleton note -> canonical game_item shape, title falls back to filename', () => { + const fm = { type: 'game', played: 'true', personalRating: '3' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\nsome notes', filename: 'Avatar.md' }; + const content = gameSpec.convertLocal(ctx); + expect(content).toContain('type: game_item'); + expect(content).toContain('title: Avatar'); + expect(content).toContain('play_status: Played'); + expect(content).toContain('rating: 3'); + expect(content).toContain('## My Notes'); + expect(content).toContain('some notes'); + }); + + test('preserves custom sections through conversion', () => { + const ctx: LibraryNoteCtx = { + frontmatter: {}, + body: '## Mods\n\nreshade\n\n## My Notes\n\nkeep me', + filename: 'X.md', + }; + const content = gameSpec.convertLocal(ctx); + expect(content).toContain('## Mods'); + expect(content).toContain('reshade'); + expect(content).toContain('keep me'); + }); + + test('round-trip idempotence: re-running convertLocal on its own output yields byte-identical content', () => { + const fm = { type: 'game', played: 'false', personalRating: '', url: 'https://store.steampowered.com/app/2379780/Avatar/' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: 'Avatar.md' }; + const once = gameSpec.convertLocal(ctx); + const { frontmatter: fm2, body: body2 } = (() => { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(once)!; + const f: Record = {}; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) f[mm[1]] = mm[2].trim(); + } + return { frontmatter: f, body: m[2] }; + })(); + const twice = gameSpec.convertLocal({ frontmatter: fm2, body: body2, filename: 'Avatar.md' }); + expect(twice).toBe(once); + }); +}); diff --git a/tests/library-manga.test.ts b/tests/library-manga.test.ts index c495e75..53fd989 100644 --- a/tests/library-manga.test.ts +++ b/tests/library-manga.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { buildManga, buildMangaFromAniList, renderManga, mangaSpec, type MangaRecord } from 'packages/obsidian/src/library/manga'; +import { buildManga, buildMangaFromAniList, buildMangaLocal, renderManga, mangaSpec, type MangaRecord } from 'packages/obsidian/src/library/manga'; import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; import jikanFixture from 'tests/fixtures/jikan-manga-csm.json'; @@ -1089,3 +1089,103 @@ describe('mangaSpec.sync — AniList primary enrich', () => { expect(deps.notifyCalls).toEqual(['«Chainsaw Man» ch. 214 out']); }); }); + +describe('buildMangaLocal: pure prev-only mapper (no API payload)', () => { + test('empty prev + filename fallback -> title from filename, everything else empty/null', () => { + const r = buildMangaLocal({}, 'Berserk.md'); + expect(r.title).toBe('Berserk'); + expect(r.readStatus).toBe('Unread'); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + expect(r.chapters).toBeNull(); + expect(r.volumes).toBeNull(); + expect(r.status).toBe(''); + expect(r.authors).toEqual([]); + expect(r.genre).toEqual([]); + expect(r.score).toBeNull(); + expect(r.publishedFrom).toBeNull(); + expect(r.publishedTo).toBeNull(); + expect(r.malId).toBe(''); + expect(r.anilistId).toBe(''); + expect(r.mangadexId).toBe(''); + expect(r.rss).toBe(''); + expect(r.poster).toBeNull(); + expect(r.url).toBe(''); + expect(r.synopsis).toBe(''); + }); + + test('prev title wins over filename', () => { + expect(buildMangaLocal({ title: 'Chainsaw Man' }, 'Berserk.md').title).toBe('Chainsaw Man'); + }); + + test('skeleton legacy fields (read/personalRating) converted via existing derive helpers', () => { + const r = buildMangaLocal({ read: 'true', personalRating: '4' }, 'X.md'); + expect(r.readStatus).toBe('Read'); + expect(r.rating).toBe('4'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️'); + }); + + test('carries whatever id/tracking fields prev already has (mal_id, anilist_id, mangadex_id, rss, last_read_chapter, latest_chapter, last_chapter_date)', () => { + const prev = { + mal_id: '116778', + anilist_id: '105778', + mangadex_id: 'abc-123', + rss: 'https://example.com/feed.xml', + last_read_chapter: '150', + latest_chapter: '213', + last_chapter_date: '2026-07-16', + }; + const r = buildMangaLocal(prev, 'X.md'); + expect(r.malId).toBe('116778'); + expect(r.anilistId).toBe('105778'); + expect(r.mangadexId).toBe('abc-123'); + expect(r.rss).toBe('https://example.com/feed.xml'); + expect(r.lastReadChapter).toBe('150'); + expect(r.latestChapter).toBe(213); + expect(r.lastChapterDate).toBe('2026-07-16'); + expect(r.url).toBe('https://myanimelist.net/manga/116778'); + }); +}); + +describe('mangaSpec.convertLocal: no-network canonical conversion for id-less notes', () => { + test('stock-skeleton note -> canonical manga_item shape, title falls back to filename', () => { + const fm = { type: 'comicManga', read: 'true', personalRating: '3' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\nsome notes', filename: 'Ghostblade.md' }; + const content = mangaSpec.convertLocal(ctx); + expect(content).toContain('type: manga_item'); + expect(content).toContain('title: Ghostblade'); + expect(content).toContain('read_status: Read'); + expect(content).toContain('rating: 3'); + expect(content).toContain('## My Notes'); + expect(content).toContain('some notes'); + }); + + test('preserves custom sections through conversion', () => { + const ctx: LibraryNoteCtx = { + frontmatter: {}, + body: '## Watch Order\n\nvol 1 first\n\n## My Notes\n\nkeep me', + filename: 'X.md', + }; + const content = mangaSpec.convertLocal(ctx); + expect(content).toContain('## Watch Order'); + expect(content).toContain('vol 1 first'); + expect(content).toContain('keep me'); + }); + + test('round-trip idempotence: re-running convertLocal on its own output yields byte-identical content', () => { + const fm = { type: 'comicManga', read: 'false', personalRating: '' }; + const ctx: LibraryNoteCtx = { frontmatter: fm, body: '## My Notes\n\n', filename: 'Ghostblade.md' }; + const once = mangaSpec.convertLocal(ctx); + const { frontmatter: fm2, body: body2 } = (() => { + const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(once)!; + const f: Record = {}; + for (const line of m[1].split('\n')) { + const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (mm) f[mm[1]] = mm[2].trim(); + } + return { frontmatter: f, body: m[2] }; + })(); + const twice = mangaSpec.convertLocal({ frontmatter: fm2, body: body2, filename: 'Ghostblade.md' }); + expect(twice).toBe(once); + }); +}); From 18d90e3c1af919d526174d3cf631253f486daabe Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 6 Aug 2026 01:19:55 +0800 Subject: [PATCH 52/53] docs(readme): replace upstream README with fork guide Stock upstream README documented Moritz Jung's plugin, not this fork. Rewrite in setup-guide shape: TOC, plugin info table, watchlist frontmatter/sync-tier/watch-status-rule blocks, library type sources, command + settings tables, build/install steps, Issues with quoted error strings. Facts sourced from src: commands (main.ts), settings defaults (Settings.ts), active statuses + throttle + flip rule (SyncEngine.ts, build.ts), preserved fields (build.ts, parse.ts), TMDB v3/v4 branch (tmdb.ts), dev outDir (vite.config.mts). Upstream import/template/search-by-ID reference linked, GPL-3 credit kept. --- README.md | 463 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 297 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 5a3e090..1fe51e3 100644 --- a/README.md +++ b/README.md @@ -1,199 +1,330 @@ -## Obsidian Media DB Plugin +# Media DB Sync -A plugin that can query multiple APIs for movies, series, anime, manga, books, comics, games, music, and wiki articles, and import them into your vault. +An Obsidian plugin that fills in media metadata, re-syncs it on a schedule, and flips a series back to `Unwatched` when a new episode airs. Fork of [Media DB](https://github.com/mProjectsCode/obsidian-media-db-plugin) by Moritz Jung. -> [!WARNING] -> Please make sure you are looking at the README on the [release branch](https://github.com/mProjectsCode/obsidian-media-db-plugin/blob/release/README.md). -> The README on the master branch refers to the current in-development version of the plugin. +The stock plugin writes a note once, at import time, and never touches it again. This fork adds the part that runs afterwards. -### Features +# Table of Contents -#### Search by Title +- [Media DB Sync](#media-db-sync) +- [Table of Contents](#table-of-contents) +- [What This Fork Adds](#what-this-fork-adds) +- [Plugin Info](#plugin-info) +- [Watchlist Notes](#watchlist-notes) + - [Frontmatter written on every sync](#frontmatter-written-on-every-sync) + - [Fields the sync never overwrites](#fields-the-sync-never-overwrites) + - [Which notes get synced](#which-notes-get-synced) + - [The watch-status rule](#the-watch-status-rule) + - [Anime detection](#anime-detection) +- [Library Types](#library-types) +- [Commands](#commands) +- [Settings](#settings) +- [Related Articles](#related-articles) +- [Pre-requisites](#pre-requisites) +- [Clone Repository](#clone-repository) +- [Set Up Build Environment](#set-up-build-environment) + - [1. Install Bun](#1-install-bun) + - [2. Install Dependencies](#2-install-dependencies) + - [3. Build](#3-build) + - [4. Run the Checks](#4-run-the-checks) +- [Install Into Obsidian](#install-into-obsidian) + - [Option A: BRAT](#option-a-brat) + - [Option B: Copy the Build](#option-b-copy-the-build) + - [Configure](#configure) +- [Development](#development) + - [Live Build Into a Test Vault](#live-build-into-a-test-vault) + - [Tests](#tests) +- [Issues](#issues) + - [TMDB API key not configured](#tmdb-api-key-not-configured) + - [Watchlist sync already running](#watchlist-sync-already-running) + - [Notes skipped for having no id](#notes-skipped-for-having-no-id) + - [TMDB works in Obsidian but not from a script](#tmdb-works-in-obsidian-but-not-from-a-script) +- [License and Credit](#license-and-credit) -Search for movies, series, anime, manga, books, comics, games, music releases, or wiki articles by their name across multiple APIs. +# What This Fork Adds -#### Search by ID +| Capability | Stock Media DB | This fork | +| ----------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------ | +| Import a title from an API | Yes | Yes, unchanged | +| Re-sync an existing note later | Manual, one note at a time | Scheduled, across the whole folder | +| `language`, `country`, `imdb_id`, `content_rating`, `trailer`, `homepage` | Left blank on TMDB imports | Filled | +| `producer` | Writes `studio` instead | Individual producers | +| Episode tracking | No | `last_episode`, `upcoming_episode`, `next_air_date` | +| Watch-status automation | No | `Watched` flips to `Unwatched` on a new episode | +| Backfilling ids onto notes that predate the plugin | No | `Watchlist: resolve missing TMDB ids` | +| Manga, book, game, comic upkeep | Import only | Scheduled sync per type | -Allows you to search by an ID that varies from API to API. Concrete information on this feature can be found in the description of the individual APIs. +# Plugin Info -#### Templates +| Property | Value | +| ------------------------- | ----------------------------------------------------------------------------------------- | +| Plugin ID | `media-db-sync` | +| Display name | Media DB Sync | +| Minimum Obsidian version | 1.12.0 | +| Desktop only | No, runs on mobile | +| License | GPL-3.0 | +| Upstream | [mProjectsCode/obsidian-media-db-plugin](https://github.com/mProjectsCode/obsidian-media-db-plugin) | +| Toolchain | Bun + Vite | +| Build output | `dist/main.js`, `dist/manifest.json`, `dist/styles.css` | +| Network calls | Obsidian `requestUrl()` only, never node `fetch` | -The plugin allows you to set a template note that gets added to the end of any note created by this plugin. -The plugin also offers simple template tags, for example `{{ title }}`, which will be replaced by the title of the media being imported. -Note that template tags are surrounded by two curly braces and spaces. The spaces inside the curly braces are important! +# Watchlist Notes -For arrays, there are two special ways of displaying them: +A watchlist note is one markdown file per title, carrying `type: watchlist_item` in its frontmatter. Everything the sync reads and writes lives in that frontmatter; the body holds the rendered sections. -- using `{{ LIST:variable_name }}` will result in: - ``` - - element 1 - - element 2 - - element 3 - - ... - ``` -- using `{{ ENUM:variable_name }}` will result in: - ``` - element 1, element 2, element 3, ... - ``` - -Available variables that can be used in template tags are any front-matter properties. - -I also published my own templates [here](https://github.com/mProjectsCode/obsidian-media-db-templates). - -#### Download poster images - -The plugin offers a setting to automatically download the poster images for a new media, ensuring offline access. The images are saved as `type_title (year)` e.g. `movie_The Perfect Storm (2000)`, in a user-chosen folder. - -#### Metadata field customization - -Allows you to rename the metadata fields this plugin generates through mappings. The mappings can be set in the plugin's settings. -The three options for mapping are: - -- `default`: Keep the original name -- `remap`: Rename the property -- `remove`: Removes the property entirely - -#### Bulk Import - -The plugin allows you to import your preexisting media collection and upgrade it to Media DB entries. - -##### Prerequisites - -The preexisting media notes must be inside a folder in your vault. -For the plugin to be able to query them, they need one metadata field that is used as the title the piece of media is searched by. -This can be achieved by, for example, using a `csv` import plugin to import an existing list from outside of Obsidian. - -##### Importing - -To start the import process, right-click on the folder and select the `Import folder as Media DB entries` option. -Then specify the API to search, if the current note content and metadata should be appended to the Media DB entry, and the name of the metadata field that contains the title of the piece of media. - -Then the plugin will go through every file in the folder and prompt you to select from the search results. - -##### Post import - -After all files have been imported or the import was canceled, you will find the new entries as well as an error report that contains any errors or skipped/canceled files in the folder specified in the setting of the plugin. - -### How to install - -**The plugin is now released, so it can be installed directly through Obsidian's plugin installer.** - -Alternatively, you can manually download the zip archive from the latest release here on GitHub. -After downloading, extract the archive into the `.obsidian/plugins` folder in your vault. - -The folder structure should look like this: +### Frontmatter written on every sync ``` -[path to your vault] -|_ .obsidian - |_ plugins - |_ obsidian-media-db-plugin - |_ main.js - |_ manifest.json - |_ styles.css +title engName media_type category +watch_status rating rating_stars year +runtime seasons episodes vod +genre status language country +director writer producer content_rating +tmdb_rating tmdb_id imdb_id release_date +last_air_date next_air_date last_episode upcoming_episode +poster trailer homepage imdb_page +notion_url synopsis cast ``` -### How to use +`media_type` is `Movie` or `TV Series`. `category` is `Movie`, `Series`, or `Anime`. -Once you have installed this plugin, you will find a database icon in the left ribbon. -When using this or the `Add new Media DB entry` command, a pop-up will open. -Here, you can enter the title of what you want to search for and then select which APIs to search. +Body sections owned by the renderer: `## Synopsis`, `## Cast`, `## Links`, `## My Notes`. -After clicking search, a new pop-up will open, prompting you to select from the search results. -Now you select the result you want, and the plugin will cast its magic, creating a new note in your vault that contains the metadata of the selected search result. +### Fields the sync never overwrites -### Currently supported media types +``` +watch_status kept, except for the episode rule below +rating kept +rating_stars kept +notion_url kept +## My Notes body text under this heading is carried across verbatim +``` -- movies (including specials) -- series (including OVAs) -- videogames -- boardgames -- music releases -- wiki articles -- books -- manga -- comics +A note with no `watch_status` yet starts at `Unwatched`. -### Currently supported APIs: +### Which notes get synced -| Name | Description | Supported formats | Authentification | Rate limiting | SFW filter support | -| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ | -| [Jikan](https://jikan.moe/) | Jikan is an API that uses [My Anime List](https://myanimelist.net) and offers metadata for anime. | series, movies, specials, OVAs, manga, manwha, novels | No | 60 per minute and 3 per second | Yes | -| [OMDb](https://www.omdbapi.com/) | OMDb is an API that offers metadata for movies, series, and games. | series, movies, games | Yes, you can get a free key here [here](https://www.omdbapi.com/apikey.aspx) | 1000 per day | No | -| [TMDB](https://www.themoviedb.org/) | TMDB is a API that offers community editable metadata for movies and series. | series, movies | Yes, by making an account [here](https://www.themoviedb.org/signup) and getting your `API Read Access Token` (**not** `API Key`) [here](https://www.themoviedb.org/settings/api) | 50 per second | Yes | -| [MusicBrainz](https://musicbrainz.org/) | MusicBrainz is an API that offers information about music releases. | music releases | No | 50 per second | No | -| [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) | The Wikipedia API allows access to all Wikipedia articles. | wiki articles | No | None | No | -| [Steam](https://store.steampowered.com/) | The Steam API offers information on all Steam games. | games | No | 10000 per day | No | -| [Open Library](https://openlibrary.org) | The OpenLibrary API offers metadata for books | books | No | Cover access is rate-limited when not using CoverID or OLID by max 100 requests/IP every 5 minutes. This plugin uses OLID, so there shouldn't be a rate limit. | No | -| Comic Vine | The Comic Vine API offers metadata for comic books | comicbooks | Yes, by making an account [here](https://comicvine.gamespot.com/login-signup/) and going to the [api section](https://comicvine.gamespot.com/api/) of the site | 200 requests per resource, per hour. There is also a velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No | -| [VNDB](https://vndb.org/) | The VNDB API offers metadata for visual novels | games | No | 200 requests per 5 minutes | Yes | -| [Boardgame Geek](https://boardgamegeek.com) | The Boardgame Geek API offers metadata for boardgames | boardgames | Yes, by making an account [here](https://boardgamegeek.com/join/) and then [requesting an application token](https://boardgamegeek.com/applications) | Exact usage limits are still undetermined | No | +`Watchlist: sync now` skips anything inactive, so a daily run costs a handful of requests instead of one per note. A note counts as active when either of these holds: -#### Notes +``` +status is one of: Returning Series, In Production, Planned, Pilot +watch_status is Watching +``` -- [Jikan](https://jikan.moe/) - - sometimes the api is very slow; this is normal - - you need to use the title the anime has on [My Anime List](https://myanimelist.net), which is in most cases the Japanese title - - e.g. instead of "Demon Slayer" you have to search "Kimetsu no Yaiba" -- Support for the [Moby Games](https://www.mobygames.com) API has been removed from the plugins as the API is no longer free to use. -- Support for the [Giant Bomb](https://www.giantbomb.com) API has been removed from the plugin temporarily as their API is [currently non functional](https://giantbomb.com/api). +Everything else is only touched by `Watchlist: full sync`. Requests are spaced 250 ms apart by default. -#### Search by ID +### The watch-status rule -- [Jikan](https://jikan.moe/) - - the ID you need is the ID of the anime on [My Anime List](https://myanimelist.net) - - you can find this ID in the URL - - e.g. for "Beyond the Boundary" the URL looks like this `https://myanimelist.net/anime/18153/Kyoukai_no_Kanata` so the ID is `18153` -- [Jikan Manga](https://jikan.moe/) - - the ID you need is the ID of the manga on [My Anime List](https://myanimelist.net) - - you can find this ID in the URL - - e.g. for "All You Need Is Kill" the URL looks like this `https://myanimelist.net/manga/62887/All_You_Need_Is_Kill` so the ID is `62887` -- [OMDb](https://www.omdbapi.com/) - - the ID you need is the ID of the movie or show on [IMDb](https://www.imdb.com) - - you can find this ID in the URL - - e.g. for "Rogue One" the URL looks like this `https://www.imdb.com/title/tt3748528/` so the ID is `tt3748528` -- [TMDB](https://www.themoviedb.org/) - - the ID you need is the numeric value in the URL directly following `/movie/` or `/tv/` - - e.g. for "Stargate" the URL looks like this `https://www.themoviedb.org/movie/2164-stargate` so the ID is `2164` - - When searching by ID you need to select `TMDBSeriesAPI`, `TMDBSeasonAPI`, or `TMDBMovieAPI` for series, seasons, and movies respectively. - - Season ID searches use the format `/season/` - season 1 of The Expanse expects `63639/season/1` -- [MusicBrainz](https://musicbrainz.org/) - - the id of a release is not easily accessible; you are better off just searching by title - - the search is generally for albums but you can have a more granular search like so: - - search for albums by a specific `artist:"Lady Gaga" AND primarytype:"album"` - - search for a specific album by a specific artist `artist:"Lady Gaga" AND primarytype:"album" AND releasegroup:"The Fame"` - - search for a specific entry (song or album) by a specific `artist:"Lady Gaga" AND releasegroup:"Poker face"` -- [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) - - [here](https://en.wikipedia.org/wiki/Wikipedia:Finding_a_Wikidata_ID) is a guide to finding the Wikipedia ID for an article -- [Steam](https://store.steampowered.com/) - - you can find this ID in the URL - - e.g. for "Factorio" the URL looks like this `https://store.steampowered.com/app/427520/Factorio/` so the ID is `427520` -- [Open Library](https://openlibrary.org) - - The ID can either be the "/work/" ID, the "/book/" ID, or the "/isbn/" ID it needs to start with `/works/`. You can find this ID in the URL - - e.g. for "Fantastic Mr. Fox" the "/works/" URL looks like this `https://openlibrary.org/works/OL45804W` so the ID is `/works/OL45804W` - - This URL is located near the top of the page above the title, see `An edition of Fantastic Mr Fox (1970) ` - - For a specific edition of "Fantastic Mr. Fox" the "/books/" URL looks like this `https://openlibrary.org/books/OL3567303M/` so the ID is `/books/OL3567303M` - - This URL is located in the editions section` -- [Comic Vine](https://www.comicvine.gamespot.com) - - you can find this ID in the URL - - e.g. for "Boule & Bill" the URL looks like this `https://comicvine.gamespot.com/boule-bill/4050-70187/` so the ID is `4050-70187` - - Please note that only volumes can be added, not separate issues. -- [VNDB](https://vndb.org/) - - Located in the novel's VNDB URL path - - e.g. The ID for [Katawa Shoujo](https://vndb.org/v945) (`https://vndb.org/v945`) is `v945` +``` +if media is a TV series +and watch_status == "Watched" +and TMDB's last_air_date is newer than the last_air_date stored in the note +then watch_status -> "Unwatched" +``` -### Problems, unexpected behavior, or improvement suggestions? +That is the whole rule. A movie you marked `Watched` stays `Watched`, because a movie has no `last_air_date` that can move. -You are more than welcome to open an issue on [GitHub](https://github.com/mProjectsCode/obsidian-media-db-plugin/issues). +### Anime detection -### Contributions +``` +genre contains "Animation" AND language == "Japanese" -> category: Anime +otherwise, if it is a movie -> category: Movie +otherwise -> category: Series +``` -Thank you for wanting to contribute to this project. +This is why the blank `language` field on stock TMDB imports matters. Without it, every anime lands in `Series`. -Contributions are always welcome. If you have an idea, feel free to open a feature request under the issue tab or even create a pull request. +# Library Types -### Credits +Each type is a separate folder with its own toggle. All four run on the watchlist's sync interval. -Credits go to: +| Type | Default folder | Data sources | API key needed | +| ----- | -------------- | ------------------------------------------------------------- | -------------- | +| Manga | `Mangas` | Jikan (MyAnimeList), AniList, MangaDex RSS for chapter counts | None | +| Book | `Books` | Open Library | None | +| Game | `Games` | Steam store, RAWG | RAWG | +| Comic | `Comics` | Comic Vine | Comic Vine | -- https://github.com/anpigon/obsidian-book-search-plugin for some inspiration and the idea to make this plugin +When a resolve pass turns up several plausible matches for one note, it opens a picker instead of guessing. The picker offers `Never resolve (mark no_resolve)`; a note with `no_resolve: true` is skipped by every future resolve pass and left out of the "missing id" count. + +Notes that resolve to nothing still get converted into the canonical shape locally, with no API calls, so they appear in Bases views rather than staying invisible. + +# Commands + +Inherited from upstream, unchanged: + +| Command | +| ------------------------------------------------ | +| Create entry | +| Create entry (per media type) | +| Create entry (advanced search) | +| Create entry by id | +| Update open note (this will recreate the note) | +| Update metadata | +| Insert link | + +Added here: + +| Command | What it does | +| ------------------------------------------------------ | --------------------------------------------------------------- | +| `Watchlist: sync now (airing/active only)` | Syncs active notes only | +| `Watchlist: full sync (all entries)` | Syncs every note in the folder | +| `Watchlist: dry-run full sync (log only, no writes)` | Walks the full sync and logs the diff without writing | +| `Watchlist: resolve missing TMDB ids` | Searches TMDB for notes that have no `tmdb_id` | +| `Library: sync now` | One type | +| `Library: resolve ids` | Backfills ids for one type | +| `Library: dry-run full sync` | Log only | +| `Library: sync all` | Every enabled type | + +Run the dry-run against a real folder before the first live sync. If the vault is under git, commit it first. + +# Settings + +Under **Watchlist sync**: + +| Setting | Default | Notes | +| ---------------------- | ----------- | ---------------- | +| Enable watchlist sync | Off | | +| Watchlist folder | `Watchlist` | | +| Sync interval (hours) | `24` | Accepts 1 to 168 | + +Under **Library sync**, per type: an enable toggle and a folder path. + +The scheduler is catch-up based rather than a wall-clock timer. On load it checks whether `lastSync + interval` is already in the past and runs if so, so a laptop that was asleep at the scheduled hour still syncs when it wakes. + +API keys are held in the Obsidian keychain, not in `data.json`. TMDB takes either credential: a token starting with `eyJ` is sent as `Authorization: Bearer`, anything else is sent as `?api_key=`. + +# Related Articles + +- [Upstream Media DB README](https://github.com/mProjectsCode/obsidian-media-db-plugin/blob/master/README.md), for the import features, templates, property mappings, and the per-API "search by ID" reference +- [TMDB API docs](https://developer.themoviedb.org/reference/intro/getting-started) +- [BRAT](https://github.com/TfTHacker/obsidian42-brat), for installing a plugin straight from a repo +- [Obsidian Bases](https://help.obsidian.md/bases), which is what the `watchlist_item` shape is written for + +# Pre-requisites + +- Obsidian 1.12.0 or newer +- A TMDB API key or read access token, from [your TMDB API settings](https://www.themoviedb.org/settings/api) +- [Bun](https://bun.sh/) 1.x, if you are building from source +- Git + +# Clone Repository + +```bash +git clone git@github.com:afiqzudinhadi/obsidian-media-db-sync.git +cd obsidian-media-db-sync +``` + +The upstream remote is kept, so changes from Moritz Jung's plugin can be pulled in: + +```bash +git fetch upstream +``` + +# Set Up Build Environment + +### 1. Install Bun + +```bash +curl -fsSL https://bun.sh/install | bash +``` + +This project uses Bun and Vite. It does not use npm or esbuild; older notes that say otherwise are stale. + +### 2. Install Dependencies + +```bash +bun install +``` + +### 3. Build + +```bash +bun run build +``` + +Type-checks first, then writes `main.js`, `manifest.json`, and `styles.css` into `dist/`. + +### 4. Run the Checks + +```bash +bun run check +``` + +Formatter check, both type-check passes, ESLint at zero warnings, then the test suite. + +# Install Into Obsidian + +### Option A: BRAT + +Install BRAT, then add `afiqzudinhadi/obsidian-media-db-sync` as a beta plugin. BRAT reads `manifest-beta.json` and updates from releases. + +### Option B: Copy the Build + +```bash +mkdir -p /path/to/vault/.obsidian/plugins/media-db-sync +cp dist/main.js dist/manifest.json dist/styles.css /path/to/vault/.obsidian/plugins/media-db-sync/ +``` + +Reload Obsidian, then enable **Media DB Sync** under Settings, Community plugins. + +The plugin id is `media-db-sync`, not `obsidian-media-db`, so this installs alongside the stock plugin and neither one overwrites the other's folder or `data.json`. + +### Configure + +1. Paste the TMDB key into Settings, Media DB Sync. +2. Turn on **Enable watchlist sync** and set the folder. +3. Run `Watchlist: dry-run full sync` and read the console output. +4. If the diff looks right, run `Watchlist: full sync`. + +# Development + +### Live Build Into a Test Vault + +```bash +bun run dev +``` + +In development mode the output goes to `exampleVault/.obsidian/plugins/media-db-sync/` instead of `dist/`, and rebuilds on save. + +### Tests + +```bash +bun test --preload ./tests/setup.ts +``` + +Use `bun run test:log` to see the sync engine's log lines. The suite covers the serializer, YAML quoting, the diff-on-write path, the candidate picker loop, and the watch-status rule. + +# Issues + +### TMDB API key not configured + +``` +TMDB API key not configured (Media DB Sync settings). +``` + +The key sits in the Obsidian keychain, not in `data.json`, so it does not travel with a synced vault. Enter it again on each device. + +### Watchlist sync already running + +``` +Watchlist sync already running +``` + +A sync is in progress. Both a manual command and the scheduler can start one, and the second caller is turned away rather than allowed to write over the first. Wait for it to finish. The library sync reports the same way with `Library sync already running`. + +### Notes skipped for having no id + +The sync report counts notes it could not act on because they carry no `tmdb_id`. Anything imported before this plugin existed will be in that group. Run `Watchlist: resolve missing TMDB ids`, then sync again. Notes marked `no_resolve: true` are deliberate opt-outs and are excluded from the count. + +### TMDB works in Obsidian but not from a script + +``` +nodename nor servname provided, or not known +``` + +Some ISP DNS resolvers return nothing for TMDB's API host, which breaks `curl` and any Python or Node script on that connection. Obsidian's `requestUrl()` goes through Chromium's network stack and resolves it fine. That is why every network call here goes through `requestUrl()`, and why an external cron script is not a working substitute on such a connection. + +# License and Credit + +GPL-3.0, inherited from upstream. The import pipeline, API adapters, modals, and property mapping are Moritz Jung's work: see [mProjectsCode/obsidian-media-db-plugin](https://github.com/mProjectsCode/obsidian-media-db-plugin). The sync engine, watchlist schema, library specs, and resolve flow are added in this fork. From 7045a2404c85ab980f2ae6e74f4c9d6db290837b Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 6 Aug 2026 01:30:31 +0800 Subject: [PATCH 53/53] docs(readme): split user and build tracks; fix release for BRAT README interleaved plugin usage with build steps, so a user wanting only the plugin had to read past clone/bun/build. Now two tracks: install, config, commands, settings, note schema, issues first; build and dev after a divider. Also adds the standalone note (fork carries the whole upstream plugin, stock one not needed; keys and settings are not shared) and splits the bun requirement into tests (hard, bun:test + mock.module) vs build (tsc + vite, node-capable). release.yml: no tag was ever pushed, so BRAT had nothing to install. Two defects fixed: - prerelease flag only tripped on 'canary', so a beta tag would have published as stable. Now any semver prerelease tag (contains '-'). - release shipped manifest.json 0.1.0 while BRAT resolves the tag from manifest-beta.json 0.1.0-beta.1. Version mismatch makes BRAT re-download every check. Prerelease tags now ship manifest-beta.json as manifest.json. --- .github/workflows/release.yml | 11 +- README.md | 441 +++++++++++++++++++--------------- 2 files changed, 262 insertions(+), 190 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d092d66..6b45fd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,8 @@ jobs: - name: Determine prerelease status id: status run: | - if [[ "${{ github.ref }}" == *"canary"* ]]; then + # Any semver prerelease tag (0.1.0-beta.1, 0.8.0-canary.x, 1.0.0-rc.1) ships as a prerelease. + if [[ "${{ github.ref_name }}" == *"-"* ]]; then echo "prerelease=true" >> $GITHUB_OUTPUT else echo "prerelease=false" >> $GITHUB_OUTPUT @@ -38,9 +39,17 @@ jobs: - name: Build id: build + env: + TAG: ${{ github.ref_name }} run: | bun install --frozen-lockfile bun run build + # BRAT resolves the tag from manifest-beta.json, then installs the manifest.json + # shipped in that release. On a prerelease tag the two must agree, or BRAT reads + # back a different version than it installed and re-downloads forever. + if [[ "$TAG" == *"-"* ]]; then + cp manifest-beta.json dist/manifest.json + fi mkdir ${{ env.PLUGIN_NAME }} cp dist/main.js dist/manifest.json dist/styles.css ${{ env.PLUGIN_NAME }} zip -r ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip ${{ env.PLUGIN_NAME }} diff --git a/README.md b/README.md index 1fe51e3..7fb8673 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,22 @@ An Obsidian plugin that fills in media metadata, re-syncs it on a schedule, and The stock plugin writes a note once, at import time, and never touches it again. This fork adds the part that runs afterwards. +This is a complete plugin, not an add-on. The stock Media DB plugin does not need to be installed. See [Relationship to the Stock Plugin](#relationship-to-the-stock-plugin). + # Table of Contents -- [Media DB Sync](#media-db-sync) -- [Table of Contents](#table-of-contents) +**Using the plugin** + - [What This Fork Adds](#what-this-fork-adds) -- [Plugin Info](#plugin-info) +- [Relationship to the Stock Plugin](#relationship-to-the-stock-plugin) +- [Install](#install) + - [Requirements](#requirements) + - [Option A: BRAT](#option-a-brat) + - [Option B: Copy the files in by hand](#option-b-copy-the-files-in-by-hand) + - [Configure](#configure) + - [First sync](#first-sync) +- [Commands](#commands) +- [Settings](#settings) - [Watchlist Notes](#watchlist-notes) - [Frontmatter written on every sync](#frontmatter-written-on-every-sync) - [Fields the sync never overwrites](#fields-the-sync-never-overwrites) @@ -17,56 +27,151 @@ The stock plugin writes a note once, at import time, and never touches it again. - [The watch-status rule](#the-watch-status-rule) - [Anime detection](#anime-detection) - [Library Types](#library-types) -- [Commands](#commands) -- [Settings](#settings) -- [Related Articles](#related-articles) -- [Pre-requisites](#pre-requisites) -- [Clone Repository](#clone-repository) -- [Set Up Build Environment](#set-up-build-environment) - - [1. Install Bun](#1-install-bun) - - [2. Install Dependencies](#2-install-dependencies) - - [3. Build](#3-build) - - [4. Run the Checks](#4-run-the-checks) -- [Install Into Obsidian](#install-into-obsidian) - - [Option A: BRAT](#option-a-brat) - - [Option B: Copy the Build](#option-b-copy-the-build) - - [Configure](#configure) -- [Development](#development) - - [Live Build Into a Test Vault](#live-build-into-a-test-vault) - - [Tests](#tests) - [Issues](#issues) - [TMDB API key not configured](#tmdb-api-key-not-configured) - [Watchlist sync already running](#watchlist-sync-already-running) - [Notes skipped for having no id](#notes-skipped-for-having-no-id) - [TMDB works in Obsidian but not from a script](#tmdb-works-in-obsidian-but-not-from-a-script) + +**Building it yourself** + +- [Build From Source](#build-from-source) + - [Requirements](#requirements-1) + - [1. Clone](#1-clone) + - [2. Install Bun](#2-install-bun) + - [3. Install dependencies](#3-install-dependencies) + - [4. Build](#4-build) +- [Development](#development) + - [Live build into a test vault](#live-build-into-a-test-vault) + - [Tests and checks](#tests-and-checks) + - [Why Bun](#why-bun) + - [Pulling from upstream](#pulling-from-upstream) +- [Plugin Info](#plugin-info) +- [Related Articles](#related-articles) - [License and Credit](#license-and-credit) # What This Fork Adds -| Capability | Stock Media DB | This fork | -| ----------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------ | -| Import a title from an API | Yes | Yes, unchanged | -| Re-sync an existing note later | Manual, one note at a time | Scheduled, across the whole folder | -| `language`, `country`, `imdb_id`, `content_rating`, `trailer`, `homepage` | Left blank on TMDB imports | Filled | -| `producer` | Writes `studio` instead | Individual producers | -| Episode tracking | No | `last_episode`, `upcoming_episode`, `next_air_date` | -| Watch-status automation | No | `Watched` flips to `Unwatched` on a new episode | -| Backfilling ids onto notes that predate the plugin | No | `Watchlist: resolve missing TMDB ids` | -| Manga, book, game, comic upkeep | Import only | Scheduled sync per type | +| Capability | Stock Media DB | This fork | +| ------------------------------------------------------------------------- | -------------------------- | --------------------------------------------------- | +| Import a title from an API | Yes | Yes, unchanged | +| Re-sync an existing note later | Manual, one note at a time | Scheduled, across the whole folder | +| `language`, `country`, `imdb_id`, `content_rating`, `trailer`, `homepage` | Left blank on TMDB imports | Filled | +| `producer` | Writes `studio` instead | Individual producers | +| Episode tracking | No | `last_episode`, `upcoming_episode`, `next_air_date` | +| Watch-status automation | No | `Watched` flips to `Unwatched` on a new episode | +| Backfilling ids onto notes that predate the plugin | No | `Watchlist: resolve missing TMDB ids` | +| Manga, book, game, comic upkeep | Import only | Scheduled sync per type | -# Plugin Info +# Relationship to the Stock Plugin -| Property | Value | -| ------------------------- | ----------------------------------------------------------------------------------------- | -| Plugin ID | `media-db-sync` | -| Display name | Media DB Sync | -| Minimum Obsidian version | 1.12.0 | -| Desktop only | No, runs on mobile | -| License | GPL-3.0 | -| Upstream | [mProjectsCode/obsidian-media-db-plugin](https://github.com/mProjectsCode/obsidian-media-db-plugin) | -| Toolchain | Bun + Vite | -| Build output | `dist/main.js`, `dist/manifest.json`, `dist/styles.css` | -| Network calls | Obsidian `requestUrl()` only, never node `fetch` | +This fork carries the entire upstream plugin: all 16 API adapters, the search and ID modals, the ribbon icon, templates, and property mapping. Nothing in `src/` references the stock plugin, so it runs on its own. + +It uses the plugin id `media-db-sync` rather than `obsidian-media-db`, which means it can also sit alongside the stock plugin without either one touching the other's folder or `data.json`. Two consequences if you are switching over: + +| Thing | What happens | +| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| API keys | Not shared. Each plugin keeps its own keychain reference. Paste the key again here. | +| Settings: folders, templates, property mappings | Not inherited. Set them again. | +| Notes the stock plugin already wrote | Read fine. Anything with a `tmdb_id` syncs straight away; the rest go through `Watchlist: resolve missing TMDB ids` or local canonical conversion. | + +# Install + +### Requirements + +- Obsidian 1.12.0 or newer, desktop or mobile +- A TMDB API key or read access token, from [your TMDB API settings](https://www.themoviedb.org/settings/api) + +RAWG and Comic Vine keys are needed only for the game and comic library types. + +### Option A: BRAT + +The plugin is not in Obsidian's community plugin browser, so [BRAT](https://github.com/TfTHacker/obsidian42-brat) is the way to install and keep it updated. + +1. Install BRAT from the community plugin browser and enable it. +2. Run `BRAT: Add a beta plugin for testing`. +3. Enter `afiqzudinhadi/obsidian-media-db-sync`. +4. Leave "Enable after installing" checked. + +Releases are cut as prereleases, and BRAT tracks the version in `manifest-beta.json`, so `BRAT: Check for updates to all beta plugins` picks up new builds. + +### Option B: Copy the files in by hand + +Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/afiqzudinhadi/obsidian-media-db-sync/releases), or produce them yourself with [Build From Source](#build-from-source). Then: + +```bash +mkdir -p /path/to/vault/.obsidian/plugins/media-db-sync +cp main.js manifest.json styles.css /path/to/vault/.obsidian/plugins/media-db-sync/ +``` + +The folder structure ends up as: + +``` +[vault] +|_ .obsidian + |_ plugins + |_ media-db-sync + |_ main.js + |_ manifest.json + |_ styles.css +``` + +Reload Obsidian, then enable **Media DB Sync** under Settings, Community plugins. + +### Configure + +1. Paste the TMDB key into Settings, Media DB Sync. +2. Turn on **Enable watchlist sync** and point it at your folder. +3. Enable any library types you want, and set their folders. + +### First sync + +Run `Watchlist: dry-run full sync` and read the console output. It reports what it would change and writes nothing. + +If the diff looks right, run `Watchlist: full sync`. If the vault is under git, commit it first. + +# Commands + +Inherited from upstream, unchanged: + +| Command | +| ---------------------------------------------- | +| Create entry | +| Create entry (per media type) | +| Create entry (advanced search) | +| Create entry by id | +| Update open note (this will recreate the note) | +| Update metadata | +| Insert link | + +Added here: + +| Command | What it does | +| ---------------------------------------------------- | ----------------------------------------------------- | +| `Watchlist: sync now (airing/active only)` | Syncs active notes only | +| `Watchlist: full sync (all entries)` | Syncs every note in the folder | +| `Watchlist: dry-run full sync (log only, no writes)` | Walks the full sync and logs the diff without writing | +| `Watchlist: resolve missing TMDB ids` | Searches TMDB for notes that have no `tmdb_id` | +| `Library: sync now` | One type | +| `Library: resolve ids` | Backfills ids for one type | +| `Library: dry-run full sync` | Log only | +| `Library: sync all` | Every enabled type | + +# Settings + +Under **Watchlist sync**: + +| Setting | Default | Notes | +| --------------------- | ----------- | ---------------- | +| Enable watchlist sync | Off | | +| Watchlist folder | `Watchlist` | | +| Sync interval (hours) | `24` | Accepts 1 to 168 | + +Under **Library sync**, per type: an enable toggle and a folder path. All four types run on the watchlist's interval. + +The scheduler is catch-up based rather than a wall-clock timer. On load it checks whether `lastSync + interval` is already in the past and runs if so, so a laptop that was asleep at the scheduled hour still syncs when it wakes. + +API keys are held in the Obsidian keychain, not in `data.json`. TMDB takes either credential: a token starting with `eyJ` is sent as `Authorization: Bearer`, anything else is sent as `?api_key=`. # Watchlist Notes @@ -136,7 +241,7 @@ This is why the blank `language` field on stock TMDB imports matters. Without it # Library Types -Each type is a separate folder with its own toggle. All four run on the watchlist's sync interval. +Each type is a separate folder with its own toggle. | Type | Default folder | Data sources | API key needed | | ----- | -------------- | ------------------------------------------------------------- | -------------- | @@ -149,152 +254,6 @@ When a resolve pass turns up several plausible matches for one note, it opens a Notes that resolve to nothing still get converted into the canonical shape locally, with no API calls, so they appear in Bases views rather than staying invisible. -# Commands - -Inherited from upstream, unchanged: - -| Command | -| ------------------------------------------------ | -| Create entry | -| Create entry (per media type) | -| Create entry (advanced search) | -| Create entry by id | -| Update open note (this will recreate the note) | -| Update metadata | -| Insert link | - -Added here: - -| Command | What it does | -| ------------------------------------------------------ | --------------------------------------------------------------- | -| `Watchlist: sync now (airing/active only)` | Syncs active notes only | -| `Watchlist: full sync (all entries)` | Syncs every note in the folder | -| `Watchlist: dry-run full sync (log only, no writes)` | Walks the full sync and logs the diff without writing | -| `Watchlist: resolve missing TMDB ids` | Searches TMDB for notes that have no `tmdb_id` | -| `Library: sync now` | One type | -| `Library: resolve ids` | Backfills ids for one type | -| `Library: dry-run full sync` | Log only | -| `Library: sync all` | Every enabled type | - -Run the dry-run against a real folder before the first live sync. If the vault is under git, commit it first. - -# Settings - -Under **Watchlist sync**: - -| Setting | Default | Notes | -| ---------------------- | ----------- | ---------------- | -| Enable watchlist sync | Off | | -| Watchlist folder | `Watchlist` | | -| Sync interval (hours) | `24` | Accepts 1 to 168 | - -Under **Library sync**, per type: an enable toggle and a folder path. - -The scheduler is catch-up based rather than a wall-clock timer. On load it checks whether `lastSync + interval` is already in the past and runs if so, so a laptop that was asleep at the scheduled hour still syncs when it wakes. - -API keys are held in the Obsidian keychain, not in `data.json`. TMDB takes either credential: a token starting with `eyJ` is sent as `Authorization: Bearer`, anything else is sent as `?api_key=`. - -# Related Articles - -- [Upstream Media DB README](https://github.com/mProjectsCode/obsidian-media-db-plugin/blob/master/README.md), for the import features, templates, property mappings, and the per-API "search by ID" reference -- [TMDB API docs](https://developer.themoviedb.org/reference/intro/getting-started) -- [BRAT](https://github.com/TfTHacker/obsidian42-brat), for installing a plugin straight from a repo -- [Obsidian Bases](https://help.obsidian.md/bases), which is what the `watchlist_item` shape is written for - -# Pre-requisites - -- Obsidian 1.12.0 or newer -- A TMDB API key or read access token, from [your TMDB API settings](https://www.themoviedb.org/settings/api) -- [Bun](https://bun.sh/) 1.x, if you are building from source -- Git - -# Clone Repository - -```bash -git clone git@github.com:afiqzudinhadi/obsidian-media-db-sync.git -cd obsidian-media-db-sync -``` - -The upstream remote is kept, so changes from Moritz Jung's plugin can be pulled in: - -```bash -git fetch upstream -``` - -# Set Up Build Environment - -### 1. Install Bun - -```bash -curl -fsSL https://bun.sh/install | bash -``` - -This project uses Bun and Vite. It does not use npm or esbuild; older notes that say otherwise are stale. - -### 2. Install Dependencies - -```bash -bun install -``` - -### 3. Build - -```bash -bun run build -``` - -Type-checks first, then writes `main.js`, `manifest.json`, and `styles.css` into `dist/`. - -### 4. Run the Checks - -```bash -bun run check -``` - -Formatter check, both type-check passes, ESLint at zero warnings, then the test suite. - -# Install Into Obsidian - -### Option A: BRAT - -Install BRAT, then add `afiqzudinhadi/obsidian-media-db-sync` as a beta plugin. BRAT reads `manifest-beta.json` and updates from releases. - -### Option B: Copy the Build - -```bash -mkdir -p /path/to/vault/.obsidian/plugins/media-db-sync -cp dist/main.js dist/manifest.json dist/styles.css /path/to/vault/.obsidian/plugins/media-db-sync/ -``` - -Reload Obsidian, then enable **Media DB Sync** under Settings, Community plugins. - -The plugin id is `media-db-sync`, not `obsidian-media-db`, so this installs alongside the stock plugin and neither one overwrites the other's folder or `data.json`. - -### Configure - -1. Paste the TMDB key into Settings, Media DB Sync. -2. Turn on **Enable watchlist sync** and set the folder. -3. Run `Watchlist: dry-run full sync` and read the console output. -4. If the diff looks right, run `Watchlist: full sync`. - -# Development - -### Live Build Into a Test Vault - -```bash -bun run dev -``` - -In development mode the output goes to `exampleVault/.obsidian/plugins/media-db-sync/` instead of `dist/`, and rebuilds on save. - -### Tests - -```bash -bun test --preload ./tests/setup.ts -``` - -Use `bun run test:log` to see the sync engine's log lines. The suite covers the serializer, YAML quoting, the diff-on-write path, the candidate picker loop, and the watch-status rule. - # Issues ### TMDB API key not configured @@ -325,6 +284,110 @@ nodename nor servname provided, or not known Some ISP DNS resolvers return nothing for TMDB's API host, which breaks `curl` and any Python or Node script on that connection. Obsidian's `requestUrl()` goes through Chromium's network stack and resolves it fine. That is why every network call here goes through `requestUrl()`, and why an external cron script is not a working substitute on such a connection. +--- + +Everything below is for building and modifying the plugin. Skip it if you only want to use it. + +# Build From Source + +### Requirements + +- [Bun](https://bun.sh/) 1.x +- Git + +### 1. Clone + +```bash +git clone git@github.com:afiqzudinhadi/obsidian-media-db-sync.git +cd obsidian-media-db-sync +``` + +### 2. Install Bun + +```bash +curl -fsSL https://bun.sh/install | bash +``` + +### 3. Install dependencies + +```bash +bun install +``` + +### 4. Build + +```bash +bun run build +``` + +Type-checks, then writes `main.js`, `manifest.json`, and `styles.css` into `dist/`. Those are the three files the [manual install](#option-b-copy-the-files-in-by-hand) step copies into a vault. + +### Cutting a release + +Pushing a tag triggers `.github/workflows/release.yml`, which builds and attaches those three files plus a zip to a GitHub release. A tag containing a hyphen is treated as a semver prerelease: it is published with the prerelease flag, and `manifest-beta.json` is shipped in place of `manifest.json` so the installed version matches the tag BRAT resolved. + +```bash +git tag 0.1.0-beta.2 +git push origin 0.1.0-beta.2 +``` + +Bump the version in `manifest-beta.json` first, and keep it equal to the tag. + +# Development + +### Live build into a test vault + +```bash +bun run dev +``` + +In development mode the output goes to `exampleVault/.obsidian/plugins/media-db-sync/` instead of `dist/`, and rebuilds on save. + +### Tests and checks + +```bash +bun test --preload ./tests/setup.ts # or: bun run test +bun run check # format check, typecheck, lint at zero warnings, tests +``` + +`bun run test:log` adds the sync engine's log lines. The suite covers the serializer, YAML quoting, the diff-on-write path, the candidate picker loop, and the watch-status rule. + +### Why Bun + +The toolchain comes from upstream; the `scripts` block is unchanged from Moritz Jung's plugin. It matters in two different degrees: + +- **Tests need Bun.** All 25 test files import from `bun:test`, and `tests/setup.ts` calls `mock.module('obsidian', ...)` to stub the Obsidian API, which has no runtime outside the app. Another runner means rewriting the imports and that mock layer. +- **The build does not, strictly.** It is `tsc` plus `vite`, both plain node tools; only the script string hardcodes `bun run tsc`. Note that `bun.lock` is the only committed lockfile, so `npm install` resolves fresh rather than reproducing the locked tree. + +### Pulling from upstream + +The upstream remote is kept: + +```bash +git fetch upstream +``` + +# Plugin Info + +| Property | Value | +| ------------------------ | ----------------------------------------------------------------------------------------------------- | +| Plugin ID | `media-db-sync` | +| Display name | Media DB Sync | +| Minimum Obsidian version | 1.12.0 | +| Desktop only | No, runs on mobile | +| License | GPL-3.0 | +| Upstream | [mProjectsCode/obsidian-media-db-plugin](https://github.com/mProjectsCode/obsidian-media-db-plugin) | +| Toolchain | Bun + Vite | +| Build output | `dist/main.js`, `dist/manifest.json`, `dist/styles.css` | +| Network calls | Obsidian `requestUrl()` only, never node `fetch` | + +# Related Articles + +- [Upstream Media DB README](https://github.com/mProjectsCode/obsidian-media-db-plugin/blob/master/README.md), for the import features, templates, property mappings, and the per-API "search by ID" reference +- [TMDB API docs](https://developer.themoviedb.org/reference/intro/getting-started) +- [BRAT](https://github.com/TfTHacker/obsidian42-brat), for installing a plugin straight from a repo +- [Obsidian Bases](https://help.obsidian.md/bases), which is what the `watchlist_item` shape is written for + # License and Credit GPL-3.0, inherited from upstream. The import pipeline, API adapters, modals, and property mapping are Moritz Jung's work: see [mProjectsCode/obsidian-media-db-plugin](https://github.com/mProjectsCode/obsidian-media-db-plugin). The sync engine, watchlist schema, library specs, and resolve flow are added in this fork.