diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b45fd8..e7202e7 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: media-db-sync # Change this to the name of your plugin-id folder + PLUGIN_NAME: obsidian-media-db-plugin # Change this to the name of your plugin-id folder jobs: build: @@ -25,8 +25,7 @@ jobs: - name: Determine prerelease status id: status run: | - # 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 + if [[ "${{ github.ref }}" == *"canary"* ]]; then echo "prerelease=true" >> $GITHUB_OUTPUT else echo "prerelease=false" >> $GITHUB_OUTPUT @@ -39,17 +38,9 @@ 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 7fb8673..5a3e090 100644 --- a/README.md +++ b/README.md @@ -1,393 +1,199 @@ -# Media DB Sync +## Obsidian Media DB Plugin -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. +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. -The stock plugin writes a note once, at import time, and never touches it again. This fork adds the part that runs afterwards. +> [!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. -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). +### Features -# Table of Contents +#### Search by Title -**Using the plugin** +Search for movies, series, anime, manga, books, comics, games, music releases, or wiki articles by their name across multiple APIs. -- [What This Fork Adds](#what-this-fork-adds) -- [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) - - [Which notes get synced](#which-notes-get-synced) - - [The watch-status rule](#the-watch-status-rule) - - [Anime detection](#anime-detection) -- [Library Types](#library-types) -- [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) +#### Search by ID -**Building it yourself** +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. -- [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) +#### Templates -# What This Fork Adds +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! -| 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 | +For arrays, there are two special ways of displaying them: -# Relationship to the Stock Plugin +- 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, ... + ``` -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. +Available variables that can be used in template tags are any front-matter properties. -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: +I also published my own templates [here](https://github.com/mProjectsCode/obsidian-media-db-templates). -| 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. | +#### Download poster images -# Install +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. -### Requirements +#### Metadata field customization -- 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) +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: -RAWG and Comic Vine keys are needed only for the game and comic library types. +- `default`: Keep the original name +- `remap`: Rename the property +- `remove`: Removes the property entirely -### Option A: BRAT +#### Bulk Import -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. +The plugin allows you to import your preexisting media collection and upgrade it to Media DB entries. -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. +##### Prerequisites -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. +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. -### Option B: Copy the files in by hand +##### Importing -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: +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. -```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/ -``` +Then the plugin will go through every file in the folder and prompt you to select from the search results. -The folder structure ends up as: +##### 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: ``` -[vault] +[path to your vault] |_ .obsidian |_ plugins - |_ media-db-sync + |_ obsidian-media-db-plugin |_ 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 - -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. - -### Frontmatter written on every sync - -``` -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 -``` - -`media_type` is `Movie` or `TV Series`. `category` is `Movie`, `Series`, or `Anime`. - -Body sections owned by the renderer: `## Synopsis`, `## Cast`, `## Links`, `## My Notes`. - -### Fields the sync never overwrites - -``` -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 -``` - -A note with no `watch_status` yet starts at `Unwatched`. - -### Which notes get synced - -`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: - -``` -status is one of: Returning Series, In Production, Planned, Pilot -watch_status is Watching -``` - -Everything else is only touched by `Watchlist: full sync`. Requests are spaced 250 ms apart by default. - -### The watch-status rule - -``` -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" -``` - -That is the whole rule. A movie you marked `Watched` stays `Watched`, because a movie has no `last_air_date` that can move. - -### Anime detection - -``` -genre contains "Animation" AND language == "Japanese" -> category: Anime -otherwise, if it is a movie -> category: Movie -otherwise -> category: Series -``` - -This is why the blank `language` field on stock TMDB imports matters. Without it, every anime lands in `Series`. - -# Library Types - -Each type is a separate folder with its own toggle. - -| 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 | - -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. - -# 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. - ---- - -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. +### How to use + +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. + +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. + +### Currently supported media types + +- movies (including specials) +- series (including OVAs) +- videogames +- boardgames +- music releases +- wiki articles +- books +- manga +- comics + +### Currently supported APIs: + +| 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 | + +#### Notes + +- [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). + +#### Search by ID + +- [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` + +### Problems, unexpected behavior, or improvement suggestions? + +You are more than welcome to open an issue on [GitHub](https://github.com/mProjectsCode/obsidian-media-db-plugin/issues). + +### Contributions + +Thank you for wanting to contribute to this project. + +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. + +### Credits + +Credits go to: + +- https://github.com/anpigon/obsidian-book-search-plugin for some inspiration and the idea to make this plugin diff --git a/docs/superpowers/plans/2026-07-29-watchlist-suite.md b/docs/superpowers/plans/2026-07-29-watchlist-suite.md deleted file mode 100644 index 81eec32..0000000 --- a/docs/superpowers/plans/2026-07-29-watchlist-suite.md +++ /dev/null @@ -1,1580 +0,0 @@ -# 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. diff --git a/docs/superpowers/plans/2026-08-03-phase2-multitype.md b/docs/superpowers/plans/2026-08-03-phase2-multitype.md deleted file mode 100644 index 0a852b9..0000000 --- a/docs/superpowers/plans/2026-08-03-phase2-multitype.md +++ /dev/null @@ -1,220 +0,0 @@ -# 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. diff --git a/docs/superpowers/specs/2026-08-03-phase2-multitype-design.md b/docs/superpowers/specs/2026-08-03-phase2-multitype-design.md deleted file mode 100644 index 18ace37..0000000 --- a/docs/superpowers/specs/2026-08-03-phase2-multitype-design.md +++ /dev/null @@ -1,89 +0,0 @@ -# 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). diff --git a/manifest-beta.json b/manifest-beta.json index 9482269..d633e00 100644 --- a/manifest-beta.json +++ b/manifest-beta.json @@ -1,10 +1,11 @@ { - "id": "media-db-sync", - "name": "Media DB Sync", - "version": "0.1.0-beta.1", + "id": "obsidian-media-db-plugin", + "name": "Media DB", + "version": "0.8.0-canary.20260703T094912", "minAppVersion": "1.12.0", - "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", + "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", "isDesktopOnly": false } diff --git a/manifest.json b/manifest.json index 1fa516a..5e91e7f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,11 @@ { - "id": "media-db-sync", - "name": "Media DB Sync", - "version": "0.1.0", + "id": "obsidian-media-db-plugin", + "name": "Media DB", + "version": "0.8.0", "minAppVersion": "1.12.0", - "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", + "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", "isDesktopOnly": false } diff --git a/package.json b/package.json index 379e53e..f335e85 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "media-db-sync", - "version": "0.1.0", - "description": "Media library with automatic metadata enrichment, periodic sync, and watch-status automation. Fork of Media DB by Moritz Jung.", + "name": "obsidian-media-db-plugin", + "version": "0.8.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": { "dev": "vite build --watch --mode development", @@ -18,7 +18,7 @@ "release": "lemons-automation release" }, "keywords": [], - "author": "Afiq Zudin Hadi", + "author": "Moritz Jung", "license": "GPL-3.0", "devDependencies": { "@happy-dom/global-registrator": "^20.10.4", diff --git a/packages/obsidian/src/library/CandidatePickerModal.ts b/packages/obsidian/src/library/CandidatePickerModal.ts deleted file mode 100644 index 52a9c89..0000000 --- a/packages/obsidian/src/library/CandidatePickerModal.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 -} - -/** - * Fuzzy picker for an ambiguous resolve() result. `pick()` resolves to the chosen candidate's - * 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 | 'never' | 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, 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); - } - - onClose(): void { - // 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. - setTimeout(() => this.settle(null), 0); - this.contentEl.empty(); - } - - private settle(index: number | 'never' | 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 deleted file mode 100644 index 69e785c..0000000 --- a/packages/obsidian/src/library/LibraryController.ts +++ /dev/null @@ -1,304 +0,0 @@ -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, 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'; - -/** 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, convertedLocal: 0, flipped: [], errors: [] }; -} - -function emptyResolveReport(): LibraryResolveReport { - return { resolved: [], ambiguous: [], needsChoice: [], skippedNoResolve: 0, errors: [] }; -} - -export class LibraryController { - private syncing = false; - private specs: MediaTypeSpec[] = LIBRARY_TYPES.map(t => t.spec); - - constructor(private plugin: MediaDbPlugin) {} - - private notify(msg: string, timeout?: number): void { - new Notice(msg, timeout); - } - - 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(); - }; - } - - // 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 => { - if (dryRun) { - console.log(`[media-db-library] [dry] ${msg}`); - } else { - this.notify(msg); - } - }, - }; - } - - private makeDeps(spec: MediaTypeSpec, dryRun = false): 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(dryRun), - }; - } - - /** 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, quiet: boolean): void { - const req = KEY_REQUIREMENT[spec.typeName]; - if (!req || this.getKey(req)) return; - 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, 0); - } - } - - /** 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.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)`; - 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(this.buildSyncSummary(spec, mode, report), 0); - } - 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; - } - } - - /** 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'); - return emptyResolveReport(); - } - this.syncing = true; - try { - const deps = this.makeDeps(spec, dryRun); - const report = await libraryFolderResolve(spec, deps, { dryRun }); - - 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++; - 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) { - deps.log(`picker failed for ${entry.filename}: ${e instanceof Error ? e.message : String(e)}`); - skipped++; - } - } - } - - 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; - } 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; - 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, 0); - } - } - 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/library/LibraryEngine.ts b/packages/obsidian/src/library/LibraryEngine.ts deleted file mode 100644 index adee18b..0000000 --- a/packages/obsidian/src/library/LibraryEngine.ts +++ /dev/null @@ -1,152 +0,0 @@ -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'; - -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; - convertedLocal: number; - flipped: string[]; - errors: { path: string; error: string }[]; -} - -export interface LibraryResolveReport { - resolved: string[]; - ambiguous: string[]; - needsChoice: { path: string; filename: string; candidates: ResolveCandidate[] }[]; - skippedNoResolve: number; - 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, convertedLocal: 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)) { - // 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++; - } - // 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)) { - 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; -} - -/** `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: [], needsChoice: [], skippedNoResolve: 0, 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 (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 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, outcome.patches, { 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/library/book.ts b/packages/obsidian/src/library/book.ts deleted file mode 100644 index 3c88eb3..0000000 --- a/packages/obsidian/src/library/book.ts +++ /dev/null @@ -1,269 +0,0 @@ -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'; - -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 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}` : '', - }; -} - -/** - * 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 ?? ''); - 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 - 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.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})`); - 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 ?? []; -} - -/** 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}` : ''}`; -} - -/** 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 ?? ''); -} - -/** 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', - folderSettingKey: 'libraryBookFolder', - enabledSettingKey: 'libraryBookEnabled', - throttleMs: 250, - - hasId(fm: Record): boolean { - return !!stripQuotes(fm['olid']); - }, - - isActive(fm: Record): boolean { - // 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 { - 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(searchQuery, deps); - if (docs.length === 0) return null; - 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) { - deps.log(`ambiguous "${searchQuery}": candidates: ${docs.slice(0, 3).map(candidateSummary).join('; ')}`); - const candidates = docs - .slice(0, 6) - .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; - } - const olid = String(pick.key ?? '').replace(/^\/works\//, ''); - return olid ? { patches: { olid } } : null; - } catch (e) { - deps.log(`book resolve failed for "${searchQuery}": ${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; - } - 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); - 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 deleted file mode 100644 index ff02ee0..0000000 --- a/packages/obsidian/src/library/comic.ts +++ /dev/null @@ -1,339 +0,0 @@ -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'; - -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; -} - -/** - * 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 ?? ''), - }; -} - -/** - * 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 = [ - '---', - '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.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}`); - } - 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'); -} - -/** - * 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 : []; -} - -/** 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}` : ''}`; -} - -/** 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 ?? ''); -} - -/** 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', - 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 - // 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; - }, - - 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'); - 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 === 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); - 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('; ')}`); - const candidates = results - .slice(0, 6) - .filter(r => r.id != null) - .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; - } 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; - } - - 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 - // 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)) { - 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'; - 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 }; - }, - - 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/convert.ts b/packages/obsidian/src/library/convert.ts deleted file mode 100644 index a87f4dc..0000000 --- a/packages/obsidian/src/library/convert.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 489537f..0000000 --- a/packages/obsidian/src/library/game.ts +++ /dev/null @@ -1,360 +0,0 @@ -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'; - -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 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 ?? '', - }; -} - -/** - * Pure mapper: prev frontmatter only (no API payload) -> 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 = [ - '---', - '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.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(''); - 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; -} - -/** 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})` : ''}`; -} - -/** 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 ?? ''); -} - -/** 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()}`, {}); - 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 { - // 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']); - }, - - // 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 { 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 { 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), detail: steamCandidateDetail(it), patches: { steam_appid: String(it.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 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 { 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('; ')}`); - } - } catch (e) { - deps.log(`game rawg search failed for "${query}": ${String(e)}`); - } - return steamCandidates.length ? { candidates: steamCandidates } : 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 - }, - - 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 deleted file mode 100644 index 103e7f4..0000000 --- a/packages/obsidian/src/library/manga.ts +++ /dev/null @@ -1,646 +0,0 @@ -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'; -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 } - format - 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, format_not_in: [NOVEL]) { - ${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; - 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; - anilistId: string; - mangadexId: string; - rss: string; - poster: string | null; - url: string; - synopsis: string; -} - -interface ChapterUpdate { - chapter: number | null; - 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, - * 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 = stripNullSentinel(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: stripNullSentinel(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) : '', - anilistId: stripQuotes(prev['anilist_id']), // Jikan has no AniList concept -- carried through untouched - mangadexId, - rss, - poster: jikan.images?.jpg?.large_image_url ?? null, - url: jikan.url ?? '', - synopsis: jikan.synopsis ?? '', - }; -} - -/** - * 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 { - 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(); -} - -/** 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 - * 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 = aniListStoryAuthors(media); - - 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})` : ''}`; -} - -/** 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; -} - -/** 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 = [ - '---', - '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}`, - `anilist_id: ${r.anilistId}`, - `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.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}`); - } - 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 = stripNullSentinel(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()); -} - -/** 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()}`, {}); - 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; - if (!pick) { - deps.log(`ambiguous "${query}": candidates: ${results.slice(0, 3).map(candidateSummary).join('; ')}`); - } - return pick ? String(pick.mal_id) : null; -} - -/** MangaDex `attributes.title` is a lang->string 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', - folderSettingKey: 'libraryMangaFolder', - enabledSettingKey: 'libraryMangaEnabled', - throttleMs: 700, // AniList's keyless rate limit is 90 req/min - - hasId(fm: Record): boolean { - 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 && !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. - // 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 { - 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 { 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), detail: candidateAniListDetail(m), patches: patch }; - }); - } - - // 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 anilistCandidates.length ? { candidates: anilistCandidates } : 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 - if (!stripQuotes(ctx.frontmatter['mangadex_id'])) { - const mangadexId = await resolveMangadexId(query, deps); - if (mangadexId) patch.mangadex_id = mangadexId; - } - return { patches: 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']); - const anilistId = stripQuotes(fm['anilist_id']); - if (!malId && !anilistId) return null; // needs resolve() first - - let record: MangaRecord; - try { - 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) { - 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 prevStatus = stripQuotes(fm['status']); - const prevLatestChapter = parseNumOrNull(fm['latest_chapter']); - const prevLastChapterDate = stripNullSentinel(stripQuotes(fm['last_chapter_date'])); - - let flipped = false; - const newInfo = await resolveChapterUpdate(fm, deps); - if (newInfo) { - 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; - // 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`); - } - } - } - - // finish-flip: series completed while user had marked it Read - 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)); - 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/rss.ts b/packages/obsidian/src/library/rss.ts deleted file mode 100644 index 5f5d681..0000000 --- a/packages/obsidian/src/library/rss.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 793058e..0000000 --- a/packages/obsidian/src/library/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -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; - body: string; - 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) - 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) -} - -/** 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. - folderSettingKey: string; - enabledSettingKey: string; - throttleMs: number; - hasId(fm: Record): boolean; - 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 { - 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/main.ts b/packages/obsidian/src/main.ts index 4a953aa..9d3be8b 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -1,5 +1,5 @@ import 'packages/obsidian/src/styles.css'; -import { Notice, Plugin, TFolder } from 'obsidian'; +import { 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'; @@ -16,7 +16,6 @@ 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'; @@ -33,7 +32,6 @@ 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; @@ -46,8 +44,6 @@ export default class MediaDbPlugin extends Plugin { bulkImportHelper!: BulkImportHelper; dateFormatter!: DateFormatter; errorReporter!: ErrorReporter; - watchlist!: WatchlistController; - library!: LibraryController; async onload(): Promise { this.mediaTypeManager = new MediaTypeManager(); @@ -64,15 +60,6 @@ export default class MediaDbPlugin extends Plugin { this.addSettingTab(new MediaDbSettingTab(this.app, this)); this.registerRibbonAndFileMenu(); this.registerCommands(); - - this.watchlist = new WatchlistController(this); - 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(catchUp, 3600_000)); } onunload(): void {} @@ -98,7 +85,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('media-db-sync-ribbon-class'); + ribbonIconEl.addClass('obsidian-media-db-plugin-ribbon-class'); this.registerEvent( this.app.workspace.on('file-menu', (menu, file) => { @@ -181,50 +168,6 @@ 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).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), 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), 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), 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), 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), 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), 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), 0)), - }); } private getLegacyApiKeyEntries(diskSettings: Record): LegacyApiKeyEntry[] { @@ -241,7 +184,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; diff --git a/packages/obsidian/src/settings/Settings.ts b/packages/obsidian/src/settings/Settings.ts index 034e93d..9f54d7c 100644 --- a/packages/obsidian/src/settings/Settings.ts +++ b/packages/obsidian/src/settings/Settings.ts @@ -119,21 +119,6 @@ export interface MediaDbPluginSettings { propertyMappingModels: PropertyMappingModelData[]; - watchlistEnabled: boolean; - watchlistFolder: string; - 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; @@ -393,21 +378,6 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { propertyMappingModels: [], - watchlistEnabled: false, - watchlistFolder: 'Watchlist', - 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: '', @@ -755,110 +725,6 @@ 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: 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/packages/obsidian/src/styles.css b/packages/obsidian/src/styles.css index 5269684..3544a5f 100644 --- a/packages/obsidian/src/styles.css +++ b/packages/obsidian/src/styles.css @@ -325,8 +325,3 @@ 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/packages/obsidian/src/utils/Utils.ts b/packages/obsidian/src/utils/Utils.ts index 9a7818a..cc5b2bd 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 = 'media-db-sync'; +export const pluginName: string = 'obsidian-media-db-plugin'; export const contactEmail: string = 'm.projects.code@gmail.com'; export const mediaDbTag: string = 'mediaDB'; export const mediaDbVersion: string = '0.8.0'; @@ -286,16 +286,10 @@ 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/packages/obsidian/src/watchlist/SyncEngine.ts b/packages/obsidian/src/watchlist/SyncEngine.ts deleted file mode 100644 index c5462b7..0000000 --- a/packages/obsidian/src/watchlist/SyncEngine.ts +++ /dev/null @@ -1,100 +0,0 @@ -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'; - -export interface SyncDeps { - listNotes(): Promise<{ path: string }[]>; - readNote(path: string): Promise; - 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; -} - -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 { - 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++; - 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), extractCustomSections(body)); - report.synced++; - if (strip(frontmatter['watch_status']) === 'Watched' && record.watchStatus === 'Unwatched') { - report.flipped.push(note.path); - } - if (rendered !== 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/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts deleted file mode 100644 index d515384..0000000 --- a/packages/obsidian/src/watchlist/WatchlistController.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Notice, TFile, TFolder } from 'obsidian'; -import type MediaDbPlugin from 'packages/obsidian/src/main'; -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'; -import { patchFrontmatter } from 'packages/obsidian/src/watchlist/patchFrontmatter'; -import { resolveNote } from 'packages/obsidian/src/watchlist/resolve'; - -export interface ResolveReport { - scanned: number; - resolved: number; - ambiguous: number; - errors: { path: string; error: string }[]; -} - -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 { - 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 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')); - err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000; - 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}`); - 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(`Watchlist 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); - }, - 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}`), - }; - } - - 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)` : ''), - 0, - ); - } - if (!dryRun) { - this.plugin.settings.watchlistLastSync = Date.now(); - await this.plugin.saveSettings(); - } - return report; - } finally { - this.syncing = false; - } - } - - 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, false, true).catch(e => console.error('[media-db-sync] scheduled sync failed', e)); - } - } - - async resolveMissingIds(dryRun = false): Promise { - 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, { tmdb_id: result.tmdbId, media_type: mediaType }, { defaultType: 'watchlist_item' })); - } - 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)` : ''), - 0, - ); - return report; - } finally { - this.syncing = false; - } - } -} diff --git a/packages/obsidian/src/watchlist/build.ts b/packages/obsidian/src/watchlist/build.ts deleted file mode 100644 index bc6d55e..0000000 --- a/packages/obsidian/src/watchlist/build.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; -import { stripQuotes } from 'packages/obsidian/src/watchlist/parse'; - -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 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[] = []; - 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 ''; -} - -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); - 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); - // 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'; - 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 prodCountries: any[] = details.production_countries ?? []; - const originCountries: string[] = details.origin_country ?? []; - 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); - 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 = 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 ---- - 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 ? 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}/` : '', - notionUrl, synopsis: details.overview ?? '', - cast: cast.join(', '), - }; -} diff --git a/packages/obsidian/src/watchlist/parse.ts b/packages/obsidian/src/watchlist/parse.ts deleted file mode 100644 index 401a281..0000000 --- a/packages/obsidian/src/watchlist/parse.ts +++ /dev/null @@ -1,67 +0,0 @@ -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() : ''; -} - -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, ''); -} - -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/packages/obsidian/src/watchlist/patchFrontmatter.ts b/packages/obsidian/src/watchlist/patchFrontmatter.ts deleted file mode 100644 index 7638c3b..0000000 --- a/packages/obsidian/src/watchlist/patchFrontmatter.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 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/packages/obsidian/src/watchlist/render.ts b/packages/obsidian/src/watchlist/render.ts deleted file mode 100644 index 186a50d..0000000 --- a/packages/obsidian/src/watchlist/render.ts +++ /dev/null @@ -1,75 +0,0 @@ -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, customSections: CustomSection[] = []): 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, ''); - 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/packages/obsidian/src/watchlist/resolve.ts b/packages/obsidian/src/watchlist/resolve.ts deleted file mode 100644 index a662d36..0000000 --- a/packages/obsidian/src/watchlist/resolve.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 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) }; -} - -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/packages/obsidian/src/watchlist/schema.ts b/packages/obsidian/src/watchlist/schema.ts deleted file mode 100644 index c832ef5..0000000 --- a/packages/obsidian/src/watchlist/schema.ts +++ /dev/null @@ -1,40 +0,0 @@ -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/tmdb.ts b/packages/obsidian/src/watchlist/tmdb.ts deleted file mode 100644 index 37af481..0000000 --- a/packages/obsidian/src/watchlist/tmdb.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/packages/obsidian/src/watchlist/yaml.ts b/packages/obsidian/src/watchlist/yaml.ts deleted file mode 100644 index 6204f41..0000000 --- a/packages/obsidian/src/watchlist/yaml.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/repo-automation.config.json b/repo-automation.config.json index 4095a4b..560b511 100644 --- a/repo-automation.config.json +++ b/repo-automation.config.json @@ -1,6 +1,6 @@ { "devBranch": "master", "releaseBranch": "release", - "github": "https://github.com/afiqzudinhadi/obsidian-media-db-sync", + "github": "https://github.com/mProjectsCode/obsidian-media-db-plugin", "preconditions": ["bun run typecheck", "bun run format", "bun run lint:fix", "bun run test"] } diff --git a/tests/fixtures/anilist-manga-csm.json b/tests/fixtures/anilist-manga-csm.json deleted file mode 100644 index 7455d11..0000000 --- a/tests/fixtures/anilist-manga-csm.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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-book.md b/tests/fixtures/canonical-book.md deleted file mode 100644 index 275d821..0000000 --- a/tests/fixtures/canonical-book.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -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 -**Rating:** ⭐️⭐️⭐️⭐️⭐️ (5/5) - -**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/canonical-comic.md b/tests/fixtures/canonical-comic.md deleted file mode 100644 index 6f01135..0000000 --- a/tests/fixtures/canonical-comic.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -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 -**Rating:** ⭐️⭐️⭐️⭐️ (4/5) -**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/canonical-game.md b/tests/fixtures/canonical-game.md deleted file mode 100644 index 3c419f1..0000000 --- a/tests/fixtures/canonical-game.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -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 -**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. - -**Developer:** Tomorrow Corporation -**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 deleted file mode 100644 index d7a0950..0000000 --- a/tests/fixtures/canonical-manga.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -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 -anilist_id: 105778 -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 -**Rating:** ⭐️⭐️⭐️⭐️ (4/5) -**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/canonical-series.md b/tests/fixtures/canonical-series.md deleted file mode 100644 index 5584847..0000000 --- a/tests/fixtures/canonical-series.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -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/fixtures/comicvine-volume.json b/tests/fixtures/comicvine-volume.json deleted file mode 100644 index c2d3d7d..0000000 --- a/tests/fixtures/comicvine-volume.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "error": "OK", - "status_code": 1, - "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/fixtures/jikan-manga-csm.json b/tests/fixtures/jikan-manga-csm.json deleted file mode 100644 index 14c5d47..0000000 --- a/tests/fixtures/jikan-manga-csm.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "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 deleted file mode 100644 index 3c2373e..0000000 --- a/tests/fixtures/mangadex-feed.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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/fixtures/openlibrary-search.json b/tests/fixtures/openlibrary-search.json deleted file mode 100644 index 9a0ad5a..0000000 --- a/tests/fixtures/openlibrary-search.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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/fixtures/rawg-game.json b/tests/fixtures/rawg-game.json deleted file mode 100644 index 9704abb..0000000 --- a/tests/fixtures/rawg-game.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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 deleted file mode 100644 index f074a43..0000000 --- a/tests/fixtures/steam-appdetails.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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/fixtures/tmdb-movie-dune2.json b/tests/fixtures/tmdb-movie-dune2.json deleted file mode 100644 index 72d5e3c..0000000 --- a/tests/fixtures/tmdb-movie-dune2.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "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 deleted file mode 100644 index 3ab5b80..0000000 --- a/tests/fixtures/tmdb-tv-loki.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "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": [ - "US" - ], - "production_countries": [ - { - "iso_3166_1": "US", - "name": "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/library-book.test.ts b/tests/library-book.test.ts deleted file mode 100644 index 4d290bc..0000000 --- a/tests/library-book.test.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -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'; - -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 () => '', - httpPostJson: 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('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)'); - 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); - }); - 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 { - 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('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: '⭐️⭐️⭐️⭐️⭐️' }; - 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({ 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({ patches: { olid: 'OL999W' } }); - }); - test('ambiguous (multiple results, no exact match) -> candidates (top ≤6, label + full patches)', 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).toEqual({ - candidates: [ - { 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' } }, - ], - }); - }); - 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 && '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 () => { - 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); - }); -}); - -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({ patches: { 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({ patches: { 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({ 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-candidate-picker-modal.test.ts b/tests/library-candidate-picker-modal.test.ts deleted file mode 100644 index b59988a..0000000 --- a/tests/library-candidate-picker-modal.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -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' } }, -]; - -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); - 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' }, - ]); - }); - - 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('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(); - 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('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(); - 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); - }); - - 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); - }); -}); diff --git a/tests/library-comic.test.ts b/tests/library-comic.test.ts deleted file mode 100644 index 4c03a3e..0000000 --- a/tests/library-comic.test.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -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'; - -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 () => '', - httpPostJson: 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('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:**'); - }); - - 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); - }); - 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', () => { - 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({ patches: { 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({ patches: { comicvine_id: '999' } }); - }); - - test('ambiguous (multiple results, no exact match) -> candidates (top ≤6, label + full patches)', 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).toEqual({ - candidates: [ - { 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' } }, - ], - }); - }); - 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 && '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); - }); - - 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(); - }); - - 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', () => { - 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('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({ - 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'); - }); -}); - -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']); - }); -}); - -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 deleted file mode 100644 index 9827b25..0000000 --- a/tests/library-controller.test.ts +++ /dev/null @@ -1,627 +0,0 @@ -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'; -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 () => '', httpPostJson: 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 + 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); - 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, false); - - 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); - }); -}); - -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); - }); -}); - -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, - convertLocal: () => '', - }; - - 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)'); - }); -}); - -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, - convertLocal: () => '', - }; - - 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('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('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[] = []; - (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 deleted file mode 100644 index 83feb02..0000000 --- a/tests/library-engine.test.ts +++ /dev/null @@ -1,524 +0,0 @@ -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; - 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, - 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 }; - }, - convertLocal: ctx => { - convertLocalCalls.push(ctx); - return opts.convertLocal ? opts.convertLocal(ctx) : renderFake(ctx.frontmatter, extractMyNotes(ctx.body)); - }, - }; - return { spec, syncCalls, resolveCalls, convertLocalCalls }; -} - -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 () => '', - httpPostJson: 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: 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(); - 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 () => ({ 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']); - 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 () => ({ patches: { 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 () => ({ 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']); - 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 { patches: { 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 () => ({ 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, {}); - 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 { patches: { 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']); - }); -}); - -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']); - }); - - 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); - }); -}); diff --git a/tests/library-game.test.ts b/tests/library-game.test.ts deleted file mode 100644 index 18d75b2..0000000 --- a/tests/library-game.test.ts +++ /dev/null @@ -1,630 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -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'; - -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 () => '', - httpPostJson: 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('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'); - }); - - 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 + play_status set -> static', () => { - expect(gameSpec.isActive({ steam_appid: '792100', play_status: 'Unplayed' })).toBe(false); - }); - test('rawg_id set + play_status set -> static', () => { - expect(gameSpec.isActive({ rawg_id: '4200', play_status: 'Unplayed' })).toBe(false); - }); - 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); - }); -}); - -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({ patches: { 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({ patches: { 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({ patches: { steam_appid: '999' } }); - }); - - 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: [ - { id: 1, name: 'Foo' }, - { id: 2, name: 'Bar' }, - ], - }), - }); - const result = await gameSpec.resolve(ctxFor({ title: '7 Billion Humans' }), deps); - expect(result).toEqual({ - candidates: [ - { 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); - }); - 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 && '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); - }); - - 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({ patches: { 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({ patches: { 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'); - }); - - 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', () => { - 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(); - }); -}); - -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 deleted file mode 100644 index 53fd989..0000000 --- a/tests/library-manga.test.ts +++ /dev/null @@ -1,1191 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -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'; -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 = {}; - -function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: string[]; logCalls: string[] } { - const notifyCalls: string[] = []; - const logCalls: string[] = []; - return { - http: async () => ({}), - httpText: async () => '', - httpPostJson: 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 -`; - -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', () => { - 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', - anilistId: '105778', - 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('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:**'); - }); - - 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); - }); - 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 { - 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, 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, 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']); - }); -}); - -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.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({ - 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({ 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({ patches: { 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('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); - 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); - }); -}); - -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({ patches: { 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({ patches: { 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({ patches: { 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({ patches: { 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({ patches: { mal_id: '116778' } }); - 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({ patches: { 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({ patches: { 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({ patches: { 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({ patches: { 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({ 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)', 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' } }, - ], - }); - }); - - 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({ patches: { 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({ patches: { 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({ 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 () => { - 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({ patches: { 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', () => { - 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']); - }); -}); - -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); - }); -}); diff --git a/tests/library-rss.test.ts b/tests/library-rss.test.ts deleted file mode 100644 index c9f555f..0000000 --- a/tests/library-rss.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -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' }); - }); -}); diff --git a/tests/setup.ts b/tests/setup.ts index f0bef47..6871828 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -36,26 +36,6 @@ 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 { @@ -63,12 +43,18 @@ mock.module('obsidian', () => ({ unload(): void {} }, DropdownComponent: class {}, - FuzzySuggestModal: class extends MockModal { - setPlaceholder(_text: string): void {} - }, MarkdownRenderer: { render: async (): Promise => {} }, MarkdownView: class {}, - Modal: MockModal, + Modal: class { + app: unknown; + + constructor(app: unknown) { + this.app = app; + } + + open(): void {} + close(): void {} + }, Notice: class {}, normalizePath: (path: string): string => path, moment: Object.assign((value?: unknown): unknown => value, { locale: (): void => {} }), diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 426e5d9..bfa58b7 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,10 +1,9 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "baseUrl": "..", "paths": { - "packages/*": ["packages/*"], - "tests/*": ["tests/*"] + "packages/*": ["../packages/*"], + "tests/*": ["./*"] }, "types": ["vite/client", "bun-types"] }, diff --git a/tests/watchlist-build.test.ts b/tests/watchlist-build.test.ts deleted file mode 100644 index 8cbe6be..0000000 --- a/tests/watchlist-build.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -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'); - }); - 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)', () => { - 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('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('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'); - }); - 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', () => { - 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: '映画 聲の形' }; - 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(''); - }); -}); diff --git a/tests/watchlist-controller.test.ts b/tests/watchlist-controller.test.ts deleted file mode 100644 index 865e671..0000000 --- a/tests/watchlist-controller.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -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 { - 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); - }); -}); - -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-parse.test.ts b/tests/watchlist-parse.test.ts deleted file mode 100644 index df0cc24..0000000 --- a/tests/watchlist-parse.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { parseNote, extractMyNotes, noteTmdbRef, extractCustomSections } 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(); - }); -}); - -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-patch-frontmatter.test.ts b/tests/watchlist-patch-frontmatter.test.ts deleted file mode 100644 index 3bb1904..0000000 --- a/tests/watchlist-patch-frontmatter.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -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'); - }); -}); diff --git a/tests/watchlist-render.test.ts b/tests/watchlist-render.test.ts deleted file mode 100644 index 9fb2e73..0000000 --- a/tests/watchlist-render.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -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:**'); - }); -}); - -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-resolve.test.ts b/tests/watchlist-resolve.test.ts deleted file mode 100644 index 245f21f..0000000 --- a/tests/watchlist-resolve.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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'); - }); - 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 deleted file mode 100644 index 5bbed62..0000000 --- a/tests/watchlist-sync-engine.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -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 = `--- -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'); - -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 }[] = []; - const fetches: string[] = []; - const deps: SyncDeps = { - 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, contents }; -} - -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)); - 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' }], - readNote: async () => 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); - }); - 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 }, - { 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'); - }); -}); diff --git a/tests/watchlist-tmdb.test.ts b/tests/watchlist-tmdb.test.ts deleted file mode 100644 index 21777a0..0000000 --- a/tests/watchlist-tmdb.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -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'); - }); -}); diff --git a/tests/watchlist-yaml.test.ts b/tests/watchlist-yaml.test.ts deleted file mode 100644 index 8719a44..0000000 --- a/tests/watchlist-yaml.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -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'); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index f570a40..bb7ef48 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,5 @@ { "compilerOptions": { - "baseUrl": ".", - "ignoreDeprecations": "6.0", "paths": { "packages/*": ["./packages/*"], "tests/*": ["./tests/*"]