diff --git a/.env.example b/.env.example index 1fb87cc..61b3f85 100644 --- a/.env.example +++ b/.env.example @@ -5,12 +5,13 @@ API_TOKEN=changeme-generate-a-long-random-token # Comma-separated origins allowed to call the API (CORS). Both Asura domains -# plus Demonic, Comix, and Kagane. Add/remove as the sites' hostnames change. -ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to +# plus Demonic, Comix, Kagane, and the two novel sites. Add/remove as the +# sites' hostnames change. +ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to,https://novelfull.com,https://lightnovelworld.net # --- Prod override (Traefik) only --- # Subdomain Traefik routes to this service (required by the prod override). -# MANGA_API_HOST=manga-api.example.com +# BOOKMARK_API_HOST=bookmark-api.example.com # Traefik's docker network name, if not "proxy". # PROXY_NETWORK=proxy # Traefik HTTPS entrypoint + cert resolver names, if yours differ from these. @@ -18,7 +19,7 @@ ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicsca # TRAEFIK_CERTRESOLVER=le # --- Web UI --- -# Password for the browser UI at https://$MANGA_WEB_HOST. Leave unset to +# Password for the browser UI at https://$BOOKMARK_WEB_HOST. Leave unset to # disable the web UI entirely (the routes are not registered at all). # Generate one: openssl rand -base64 18 WEB_PASSWORD= @@ -27,8 +28,8 @@ WEB_PASSWORD= # whether or not WEB_PASSWORD is set). Left commented on purpose: an example # value here would be a silent wrong-hostname fallback, and Traefik would # publish the UI router on a domain you do not own. The same container also -# answers on MANGA_API_HOST for the userscript's API. -# MANGA_WEB_HOST=manga.example.com +# answers on BOOKMARK_API_HOST for the userscript's API. +# BOOKMARK_WEB_HOST=bookmark.example.com # --- Latest-chapter poller --- # The backend re-checks each bookmarked series' newest published chapter on its diff --git a/.gitignore b/.gitignore index 1762a52..dead3b4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ backend/backend graphify-out/ plans/ docs/superpowers/ +.superpowers/ +go.work +go.work.sum # impeccable-ignore-start # Ephemeral output, runtime state, and per-dev overrides. diff --git a/AGENTS.md b/AGENTS.md index f03f720..abd4d64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,149 +2,27 @@ Guidance for OpenCode (and Claude Code) working in this repo. -## Status - -Active. Backend (`backend/`) and userscript (`userscript/manga-bookmark.user.js`) built. Plan `plans/mangaBookmark.md` = original spec, may drift; trust code + design docs in `docs/superpowers/specs/` over plan. - ## What this is -Manga read-progress tracker for user reading on **asurascans.com** (current domain; asuracomic.net 301s here) and **demonicscans.org** from **Bromite** (mobile Chromium). Userscript injects on-page UI (floating button + slide-in panel), syncs progress to self-hosted Go backend so bookmarks unify across both sites and devices. +Manga read-progress tracker, user read on **asurascans.com** (current domain; asuracomic.net 301s here) and **demonicscans.org** via **Violentmonkey**. Userscript inject on-page UI (floating button + slide-in panel), sync progress to self-hosted Go backend so bookmarks unify across both sites and devices. -## Hard constraints (drive design — do not violate) +## Hard constraints (drive design — don't violate) -Bromite uses Chromium's **native** userscript engine, not Tampermonkey: -- **No `GM_*` APIs anywhere.** No `GM_setValue`/`GM_getValue` (use page `localStorage`), no `GM_registerMenuCommand` (inject on-page UI), no `GM_xmlhttpRequest` for cross-origin (use plain `fetch()`). GM-free script also runs in desktop Tampermonkey/Violentmonkey for faster iteration. -- Cross-origin `fetch()` works **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block). -- Asura and Demonic = **separate origins, separate `localStorage`** — shared remote store only way to unify bookmarks. Cloud sync required, not optional. -- Userscript runs in **isolated world**, so embedded API token safe from site's JS. -- Cloudflare's block on manga sites is **IP-reputation-based, not universal — not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s w/ real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier, untested assumption CGNAT dev IP would be blocked; wasn't, at least this date. Treat "does curl work now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare bot scoring can flip clean IP without notice. Any backend fetcher still needs graceful-degrade path for when challenged; adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test. +Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines: +- **Avoid `GM_*` unless needed.** Prefer page `localStorage` over `GM_setValue`/`GM_getValue`, on-page UI over `GM_registerMenuCommand`, plain `fetch()` over `GM_xmlhttpRequest` for cross-origin. +- Cross-origin `fetch()` work **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block). +- Asura and Demonic are **separate origins with separate `localStorage`** — shared remote store only way to unify bookmarks. Cloud sync required, not optional. +- Userscript run in **isolated world**, so embedded API token safe from site's JS. +- Cloudflare's block on manga sites **IP-reputation-based, not universal — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s with real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier untested assumption CGNAT dev IP blocked; wasn't, at least this date. Treat "does curl work right now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare's bot scoring can flip previously-clean IP without notice. Backend fetcher still needs graceful-degrade path for when challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test. ## Architecture ``` -Bromite userscript (isolated world, per-site adapters, localStorage cache) +Violentmonkey userscript (isolated world, per-site adapters, localStorage cache) -- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume) ``` -- **Backend** (`backend/`): stdlib `net/http` (handful of routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`. - Single binary, split into packages under `backend/internal/`: `store` - (Bookmark type, SQLite persistence, migrations), `latest` (background - poller, site parsers, TLS fetcher), `session` (cookie signing, login - rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON - bookmark handlers), `userscript` (userscript-serving handler), `web` - (browser UI handler + `templates/` + `static/`, `go:embed`-ed). - `backend/main.go` is the composition root — the only place that wires - packages together into `newRouter`. Root-level `*_test.go` hold - integration tests that exercise the full router; unit tests for a - package live beside it under `internal/`. -- **Single-user store.** One `bookmarks` table keyed `:` (`asura`|`demonic`). Sync **last-write-wins**. Schema + endpoint list in plan. -- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth). -- **Web UI:** same binary serves password-gated browser UI on second - hostname — `GET /` (list, or login page when no session), - `POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints - under `/ui/*`. Templates + assets `go:embed`-ed under - `backend/internal/web/`, so `backend/Dockerfile` must copy the whole - `internal/` tree, not just `*.go`. Sessions = stateless - HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, when empty - web routes not registered at all. UI mutations read-modify-write - through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one - place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`. - **Design-tool caveat:** templates link `/static/style.css` root-absolutely - (correct — served from `/`), but impeccable detector resolves - stylesheet href with `path.resolve(fileDir, href)`, drops directory - on leading `/` and silently skips file. Relative hrefs don't help - either: template's directory isn't its served path. So - `detect.mjs backend/internal/web/templates` reports **false clean** — - always pass `backend/internal/web/static` too. One finding there, - `overused-font` on "Instrument Serif", deliberate identity choice, not debt. -- **Every action that moves series out of list is confirm-gated.** - Archive, finish, remove each open own `.confirm-row` disclosure - (`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈ - `archive|finish|remove`); restore fires instantly since it's the reversal. - Remove's row wears ember wash, two reversible ones wear `.calm` grey. - `--ember` stays reserved for new-chapter signal: busy bar and inline - error use `--mute`. -- **Latest-chapter poller:** ticker goroutine in same binary re-checks - each bookmarked series' newest published chapter from backend's own - network access, so `latest_chapter` stays fresh when user not - browsing. Second, parallel signal — userscript keeps own - `maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged. - Two independent clocks: per-bookmark cooldown (`latest_checked_at` column, - enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval. - Row stamped *before* fetch so broken series waits full - cooldown instead of retrying every tick; writes go through - `Store.Get` + `Store.Upsert` so new chapter never reorders list. - Fetches use `bogdanfinn/tls-client` w/ Chrome profile as defence in depth - against fingerprint-based blocking; any failure logs and skips. See - `docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`. - Poller's `Store.Get` + `Store.Upsert` not wrapped in transaction, so - userscript `PUT` committing between the two can be overwritten by - poller's stale re-read — reverting read progress and, since stored - value now differs, moving `updated_at` and reordering list. Known, - accepted limitation for single-user deployment, not bug to fix. -- **`updated_at` drives list order, moves only on real reading progress:** server applies timestamp when row new or `last_chapter_num` changes, else keeps stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**; clients must adopt that response over own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4. -- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` | - `finished`, orthogonal to `favorite`. Archived and finished appear only in - own tab — not All, Updated, Favourites, or recent strip. Poller keeps - checking archived series, skips finished ones. `finished` settable only - from web UI; `PUT /bookmarks/{key}` rejects it w/ 400. - **Empty incoming status means "keep stored one"** — resolved on - `VALUES` side of `Store.Upsert`, not conflict clause, since - `excluded.*` = post-evaluation row and default applied there'd - wipe bucket on every PUT from client predating column. See - `docs/superpowers/specs/2026-07-27-status-buckets-design.md`. -- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH` - (default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD` - (gates browser UI; unset disables it), - `LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` - (background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`). - `USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`, - default `/userscript/manga-bookmark.user.js`, supplied by a bindmount). - -### Userscript structure (single IIFE, `manga-bookmark.user.js`) - -1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. ID type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes. -2. **API client** — `apiGet/apiPut/apiDelete` w/ bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback. -3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. -4. **Retry queue** — every write goes through `pushBookmark`/`pushDelete`, so - failed mutation parked in `localStorage` (`mangabm:queue`) and replayed on - next navigation, reconnect, or `refresh()`. Entries are markers - (`{key, op, sendStatus, attempts}`), never payloads — body read from - cache at send time, so one entry per key gives ordering + coalescing for - free. `sendStatus` **sticky**: while archive pending, later writes to - that key keep carrying bucket, stops successful - in-between write from silently un-archiving series. `refresh()` drains - before fetching, overlays anything still pending, so list never - flaps. 400 drops entry, 401 aborts pass and keeps queue, - transient failures retry to cap of 10. Latest-chapter writes deliberately - stay out of queue. See - `docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`. -5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS - (critical on mobile). Three tabs (All / Favourites / Archived) + row of - link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG - block next to `API_BASE`. FAB is `7 × 44` edge tab whose *hit* area - widened to `28 × 72` by invisible `#hit` child; `#fab` must keep - `touch-action: none` and must **not** regain `overflow: hidden`. Since - `touch-action` resolved at gesture start, strip can't be both - browser-scrolled and script-dragged, so `makeDraggable` splits by intent: swipe - from `#hit` scrolls via `window.scrollBy`, hold of `ARM_MS` arms - reposition drag, visible sliver drags with no hold. See - `docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`. -6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires w/o reload. Demonic uses classic reloads (initial `document-idle` run suffices). - -### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting) - -- **asurascans.com**: series `/comics/` (slug carries a trailing - site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every - redeploy**), chapter `/comics//chapter/`. `seriesId` must strip - the hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in the userscript, - `asuraBuildHash` in the backend); URLs keep the full slug — stale-hash - URLs 302 to current ones. Astro-rendered; chapter links present in raw - server HTML. -- **demonicscans.org**: series `/manga/` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title//chapter//` (older `chaptered.php?manga=&chapter=` form still exists as redirect, what series-page chapter-list anchors link through). - Encodings (incl. triple-encoded punctuation like `%25252D`) are identical - on /manga/ and /title/ pages, so decode-once seriesIds match — verified - 2026-07-28. +Backend-specific architecture (packages, endpoints, poller, config env vars) lives in `backend/AGENTS.md`. Userscript-specific structure (adapters, retry queue, UI, live URL shapes) lives in `userscript/AGENTS.md`. ## Commands @@ -155,23 +33,23 @@ Backend (`cd backend`): Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`). -Smoke test: `curl` endpoints w/ `Authorization: Bearer `; confirm `OPTIONS` preflight returns CORS headers and `/healthz` returns 200. +Smoke test: `curl` endpoints with `Authorization: Bearer `; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200. ## Forge: Gitea, not GitHub -`origin` = self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` doesn't work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones: +`origin` is self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` don't work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones: - Open PR: `tea pr create --head --base main --title "..." --description "..."` - List / view / check out: `tea pr list`, `tea pr `, `tea pr checkout ` - Issues: `tea issue create`, `tea issue list` - Auth lives in `tea login`, not `GH_TOKEN` env var. -`tea` prints output as rendered boxes not plain text; PR URL lands on last line. +`tea` print output as rendered boxes rather than plain text; PR URL lands on last line. ## Design system Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md` -— source of truth Claude Design project `mangaBookmark Web UI` +— source of truth Claude Design project `BookmarkManager Web UI` (`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching `backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript `TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other @@ -185,7 +63,7 @@ instantly. ## Security invariants - Auth on `/bookmarks*`: require `Authorization: Bearer `, **constant-time compare**, 401 otherwise. -- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` w/ `204`. +- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` with `204`. ## Comments @@ -219,15 +97,14 @@ Test: "competent reader get this from code in few sec?" Yes → skip. Needs deto ## graphify -Project has knowledge graph at graphify-out/ w/ god nodes, community structure, cross-file relationships. +Project has knowledge graph at graphify-out/ with god nodes, community structure, cross-file relationships. Rules: -- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships, `graphify explain ""` for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. - If graphify-out/wiki/index.md exists, use for broad navigation instead of raw source browsing. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain don't surface enough context. - After modifying code, run `graphify update .` to keep graph current (AST-only, no API cost). -## OpenCode-specific +## Notes -- Caveman mode active by default (`/home/tan/.config/opencode/AGENTS.md`). Keep comms terse — drop articles, fluff, pleasantries. Code/commits/security written normal. -- `.superpowers/` and `.agents/` dirs hold skill definitions. Gitea at `gitea.violetcrown.my.id`. \ No newline at end of file +- Keep comms terse — drop articles, fluff, pleasantries. Code/commits/security written normally. diff --git a/CLAUDE.md b/CLAUDE.md index 2363ed0..c3809e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ Smoke test: `curl` endpoints with `Authorization: Bearer `; confirm `OPTI ## Design system Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md` -— source of truth Claude Design project `mangaBookmark Web UI` +— source of truth Claude Design project `BookmarkManager Web UI` (`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching `backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript `TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other diff --git a/DEPLOY.md b/DEPLOY.md index eb3f5f5..b5911e9 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -10,8 +10,8 @@ ACME/cert resolver, and control a domain. - Docker + Docker Compose on the server. - A Traefik instance watching a Docker network (default name assumed: `proxy`). -- DNS: an `A`/`AAAA` record for `manga-api.` pointing at the server. -- The repo copied to the server, e.g. `/opt/mangabm/` (needs `backend/`, +- DNS: an `A`/`AAAA` record for `bookmark-api.` pointing at the server. +- The repo copied to the server, e.g. `/opt/bookmarkmanager/` (needs `backend/`, `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example`). Confirm the Traefik network exists (create if not): @@ -25,7 +25,7 @@ docker network ls | grep proxy || docker network create proxy ## 1. Configure `.env` ```bash -cd /opt/mangabm +cd /opt/bookmarkmanager cp .env.example .env ``` @@ -39,10 +39,10 @@ API_TOKEN= ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to # Required for the Traefik override. Both have no fallback — compose refuses -# to start without them. MANGA_WEB_HOST is required even if you never set +# to start without them. BOOKMARK_WEB_HOST is required even if you never set # WEB_PASSWORD; see 1b. -MANGA_API_HOST=manga-api.violetcrown.my.id -MANGA_WEB_HOST=manga.violetcrown.my.id +BOOKMARK_API_HOST=bookmark-api.violetcrown.my.id +BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id # Only if your Traefik setup differs from these defaults: # PROXY_NETWORK=proxy @@ -67,13 +67,13 @@ grep -E '^API_TOKEN=' .env # copy this — the userscript needs the same value The browser UI is served by the same container on a second hostname. -1. Add a DNS `A`/`AAAA` record for `manga.` pointing at the server — - the same address as `manga-api.`. +1. Add a DNS `A`/`AAAA` record for `bookmark.` pointing at the server — + the same address as `bookmark-api.`. 2. Set both variables in `.env`: ```ini - MANGA_WEB_HOST=manga.violetcrown.my.id + BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id WEB_PASSWORD= ``` @@ -88,16 +88,16 @@ The browser UI is served by the same container on a second hostname. ```bash docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build - curl -s -o /dev/null -w '%{http_code}\n' https://manga.violetcrown.my.id/ + curl -s -o /dev/null -w '%{http_code}\n' https://bookmark.violetcrown.my.id/ ``` Expected `200`, serving the login page. Leaving `WEB_PASSWORD` unset is safe: the web routes are not registered and `/` -returns 404. The userscript's API on `MANGA_API_HOST` is unaffected either way. +returns 404. The userscript's API on `BOOKMARK_API_HOST` is unaffected either way. -`MANGA_WEB_HOST` itself is required by the prod override regardless — like -`MANGA_API_HOST`, its Traefik label has no fallback, so `docker compose up` +`BOOKMARK_WEB_HOST` itself is required by the prod override regardless — like +`BOOKMARK_API_HOST`, its Traefik label has no fallback, so `docker compose up` refuses to start without it even if `WEB_PASSWORD` is unset and the web UI is otherwise dormant. @@ -116,9 +116,9 @@ This merges the base file (build/image/env/volume) with the prod override (no host port, Traefik network + router labels). Always pass **both** `-f` flags — the prod file is not standalone. -Two services come up: `manga-api` (the backend) and `headless-shell`, a CDP +Two services come up: `bookmark-api` (the backend) and `headless-shell`, a CDP sidecar the poller uses to fetch kagane (behind a Cloudflare JS challenge). -It has no published port — only `manga-api` can reach it, over +It has no published port — only `bookmark-api` can reach it, over `BROWSER_WS_URL`. Missing or unreachable, the poller just skips kagane and logs it; nothing else is affected. @@ -126,7 +126,7 @@ Check it's up and healthy: ```bash docker compose -f docker-compose.yml -f docker-compose.prod.yml ps -docker logs manga-api --tail 20 # expect: "listening on :8080 ..." +docker logs bookmark-api --tail 20 # expect: "listening on :8080 ..." ``` --- @@ -137,21 +137,21 @@ Give Traefik a few seconds to issue the cert, then: ```bash # Health (no auth) — must be valid TLS, no cert warning. -curl -s https://manga-api.violetcrown.my.id/healthz # -> ok +curl -s https://bookmark-api.violetcrown.my.id/healthz # -> ok # Auth enforced. curl -s -o /dev/null -w '%{http_code}\n' \ - https://manga-api.violetcrown.my.id/bookmarks # -> 401 + https://bookmark-api.violetcrown.my.id/bookmarks # -> 401 TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) curl -s -H "Authorization: Bearer $TOKEN" \ - https://manga-api.violetcrown.my.id/bookmarks # -> [] + https://bookmark-api.violetcrown.my.id/bookmarks # -> [] # CORS preflight from a real site origin. curl -s -i -X OPTIONS \ -H 'Origin: https://asurascans.com' \ -H 'Access-Control-Request-Method: PUT' \ - https://manga-api.violetcrown.my.id/bookmarks/x | grep -i access-control + https://bookmark-api.violetcrown.my.id/bookmarks/x | grep -i access-control # -> Access-Control-Allow-Origin: https://asurascans.com (+ Methods/Headers) ``` @@ -165,7 +165,7 @@ a bad cert makes the browser block the userscript's `fetch()` (mixed content). Edit the config block at the top of `userscript/manga-bookmark.user.js`: ```js -const API_BASE = "https://manga-api.yourdomain.com"; // no trailing slash +const API_BASE = "https://bookmark-api.yourdomain.com"; // no trailing slash const API_TOKEN = ""; ``` @@ -197,7 +197,7 @@ Tampermonkey/Violentmonkey for quick checks before going mobile. ## 6. Smoke-test the full loop 1. Bookmark a series on Asura. -2. `curl -s -H "Authorization: Bearer $TOKEN" https://manga-api.yourdomain.com/bookmarks` +2. `curl -s -H "Authorization: Bearer $TOKEN" https://bookmark-api.yourdomain.com/bookmarks` on the server — the series should appear. 3. Open a chapter of that series — reopen the panel; last-read updates to that chapter (auto, never regresses on older chapters). @@ -223,7 +223,7 @@ SQLite data persists in the named volume `bookmarks-data` across rebuilds. | Symptom | Likely cause / fix | |---------|--------------------| | No cert / TLS error at the domain | `TRAEFIK_ENTRYPOINT` or `TRAEFIK_CERTRESOLVER` name wrong; or DNS not resolving yet. Check `docker logs `. | -| 404 from Traefik | Service not on the `proxy` network, or `MANGA_API_HOST` mismatch. Confirm `docker network inspect proxy` lists `manga-api`. | +| 404 from Traefik | Service not on the `proxy` network, or `BOOKMARK_API_HOST` mismatch. Confirm `docker network inspect proxy` lists `bookmark-api`. | | `fetch` fails in the userscript, `curl` works | Origin missing from `ALLOWED_ORIGINS`, or mixed content (backend not HTTPS). | | 401 with the right token | Trailing space/newline in `API_TOKEN`; regenerate and restart. | | Panel button absent | URL didn't match an adapter, or user scripts disabled in Bromite. | @@ -243,7 +243,7 @@ auto-updates come from it too. Install once, on the phone (Cromite + Violentmonkey): ``` -https://manga-api./u//manga-bookmark.user.js +https://bookmark-api./u//manga-bookmark.user.js ``` Open that URL in Cromite; Violentmonkey offers to install it. The token is in diff --git a/PRODUCT.md b/PRODUCT.md index 3540e61..a4ec0b0 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -35,7 +35,7 @@ Not a public reading tracker or social app — a private, self-hosted sync layer ## Brand Commitments -- Name: **mangaBookmark**. +- Name: **BookmarkManager**. - **Dark-first is binding**: current dark-by-default / light-follows-system-preference behavior must be preserved as a design constraint, not just a starting default, because reading happens at night. ## Evidence on Hand diff --git a/README.md b/README.md index 95be8e6..2174af3 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \ ### Deploy behind your reverse proxy -Route `https://manga-api.` → the service on `:8080` (TLS at the proxy). +Route `https://bookmark-api.` → the service on `:8080` (TLS at the proxy). - **Host proxy** (nginx/Caddy on the host): the base compose already binds `127.0.0.1:8080`; point the proxy `proxy_pass http://127.0.0.1:8080;`. @@ -99,7 +99,7 @@ Route `https://manga-api.` → the service on `:8080` (TLS at the proxy) ``` Set `PROXY_NETWORK` in `.env` if your network isn't named `proxy`. -Verify: `https://manga-api./healthz` returns `ok` over valid TLS (no +Verify: `https://bookmark-api./healthz` returns `ok` over valid TLS (no mixed-content), and an `OPTIONS` preflight from a real site origin returns the CORS headers. @@ -112,7 +112,7 @@ CORS headers. Edit the config block at the top of `userscript/manga-bookmark.user.js`: ```js -const API_BASE = "https://manga-api."; // no trailing slash +const API_BASE = "https://bookmark-api."; // no trailing slash const API_TOKEN = ""; ``` @@ -184,7 +184,7 @@ userscript does the looking, from your own browser session: (`LATEST_CHECK_BATCH` / `LATEST_CHECK_THROTTLE_MS`). Failures are silent and simply retried after the window. -Freshness is tracked per device in `localStorage` under `mangabm:lastchecked` +Freshness is tracked per device in `localStorage` under `bmgr:manga:lastchecked` and is deliberately not synced, since each device checks on its own. This means a bookmark is as current as its last check — not the moment a diff --git a/REDEPLOY.md b/REDEPLOY.md index 5b74008..9a714c1 100644 --- a/REDEPLOY.md +++ b/REDEPLOY.md @@ -9,16 +9,16 @@ Whole thing is ~5 minutes, most of it waiting on `docker build`. Order matters: **back up before you pull.** A backup taken after a bad migration is a backup of the damage. -Paths below assume the checkout is at `/opt/mangabm`; substitute your own. The -one absolute rule about paths: **backups live in `../mangabm-backups/`**, a -sibling of the project directory (`/opt/mangabm-backups`), never inside it. It +Paths below assume the checkout is at `/opt/bookmarkmanager`; substitute your own. The +one absolute rule about paths: **backups live in `../bookmarkmanager-backups/`**, a +sibling of the project directory (`/opt/bookmarkmanager-backups`), never inside it. It sits outside the repo so `git pull`, `git clean -fd` and a bad `rm -rf` inside the checkout cannot take the backups with them. ``` /opt/ -├── mangabm/ <- the checkout (this repo) -└── mangabm-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db +├── bookmarkmanager/ <- the checkout (this repo) +└── bookmarkmanager-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db ``` --- @@ -26,12 +26,12 @@ the checkout cannot take the backups with them. ## 0. Preflight ```bash -cd /opt/mangabm +cd /opt/bookmarkmanager # Both -f flags, every time. The prod override is not standalone. COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml" -$COMPOSE ps # manga-api should be Up +$COMPOSE ps # bookmark-api should be Up git status --short # expect empty git log --oneline -1 # note this hash — it is your rollback target df -h /var/lib/docker | tail -1 # a build needs room @@ -44,9 +44,9 @@ dirty tree fails halfway and leaves you in a worse spot than either. Create the backup directory once, and make sure it is a sibling, not a child: ```bash -mkdir -p ../mangabm-backups -BACKUP_DIR="$(cd .. && pwd)/mangabm-backups" # absolute — Docker needs it -echo "$BACKUP_DIR" # -> /opt/mangabm-backups +mkdir -p ../bookmarkmanager-backups +BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups" # absolute — Docker needs it +echo "$BACKUP_DIR" # -> /opt/bookmarkmanager-backups ``` --- @@ -59,7 +59,7 @@ prefixes it with the project directory: ```bash docker volume ls --filter name=bookmarks-data -# -> local mangabm_bookmarks-data +# -> local bookmarkmanager_bookmarks-data VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1) ``` @@ -172,11 +172,11 @@ bindmounted read-only and read fresh per request. ```bash $COMPOSE ps # Up, and recently (re)created -docker logs manga-api --tail 20 # -> "listening on :8080 ..." +docker logs bookmark-api --tail 20 # -> "listening on :8080 ..." ``` Nothing in the log about the database or the poller failing. The image is tagged -`mangabm-backend:latest`, so the previous image is still on disk untagged — +`bookmarkmanager-backend:latest`, so the previous image is still on disk untagged — that is what makes the rollback in §6 quick. --- @@ -186,8 +186,8 @@ that is what makes the rollback in §6 quick. Same four API checks as `DEPLOY.md` §3, plus the web UI. Set the host names once: ```bash -API=https://manga-api.violetcrown.my.id -WEB=https://manga.violetcrown.my.id +API=https://bookmark-api.violetcrown.my.id +WEB=https://bookmark.violetcrown.my.id TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) curl -s $API/healthz # -> ok @@ -281,7 +281,7 @@ docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c ' ls -l /data' $COMPOSE start -docker logs manga-api --tail 20 +docker logs bookmark-api --tail 20 curl -s -H "Authorization: Bearer $TOKEN" $API/bookmarks | head -c 200 ``` @@ -302,9 +302,9 @@ Two steps here are easy to skip and both bite: For a routine redeploy where nothing needs deciding: ```bash -cd /opt/mangabm +cd /opt/bookmarkmanager COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml" -BACKUP_DIR="$(cd .. && pwd)/mangabm-backups"; mkdir -p "$BACKUP_DIR" +BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups"; mkdir -p "$BACKUP_DIR" VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1) STAMP=$(date -u +%Y%m%d-%H%M%S) @@ -314,7 +314,7 @@ docker run --rm -v "$VOL":/data -v "$BACKUP_DIR":/backup alpine sh -c \ git pull --ff-only && $COMPOSE up -d --build && sleep 5 && -curl -sf https://manga-api.violetcrown.my.id/healthz && echo " deploy ok" +curl -sf https://bookmark-api.violetcrown.my.id/healthz && echo " deploy ok" ``` The `&&` chain is deliberate: if the backup or its integrity check fails, @@ -332,7 +332,7 @@ command can tell you the panel works on the phone. | CSS or template change did not appear | You restarted without `--build`. Assets are `//go:embed`ed. | | Font answers `application/octet-stream` | Old binary — the `.woff2` MIME registration is in `web.go`. Rebuild. | | Everyone logged out of the web UI | `API_TOKEN` or `WEB_PASSWORD` changed; sessions are derived from both. Expected, just log in again. | -| `compose` errors about `MANGA_WEB_HOST` | Run from the directory holding `.env`. Both host vars are required even when the web UI is unused. | +| `compose` errors about `BOOKMARK_WEB_HOST` | Run from the directory holding `.env`. Both host vars are required even when the web UI is unused. | | Userscript did not update on the phone | Violentmonkey polls on its own schedule; force a check. `@version` comes from the file's mtime, so confirm the pull actually touched it. | | `apk add sqlite` fails (no network) | Use the cold-copy fallback in §1 — and copy `bookmarks.db-wal` too. | | Reads work but every write fails after a restore | Restored file is root-owned; the container is uid 65532. `chown 65532:65532` it (§6). | diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..300657b --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,82 @@ +Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system. + +- **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`. + Single binary, split into packages under `backend/internal/`: `store` + (Bookmark type, SQLite persistence, migrations), `latest` (background + poller, site parsers, TLS fetcher), `session` (cookie signing, login + rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON + bookmark handlers), `userscript` (userscript-serving handler), `web` + (browser UI handler + `templates/` + `static/`, `go:embed`-ed). + `backend/main.go` is the composition root — the only place that wires + packages together into `newRouter`. Root-level `*_test.go` hold + integration tests that exercise the full router; unit tests for a + package live beside it under `internal/`. +- **Single-user store.** One `bookmarks` table keyed `:` (`asura`|`demonic`|`comix`|`kagane`). Sync **last-write-wins**. Schema and endpoint list in plan. +- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth). +- **Web UI:** same binary serve password-gated browser UI on second + hostname — `GET /` (list, or login page when no session), + `POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints + under `/ui/*`. Templates + assets `go:embed`-ed under + `backend/internal/web/`, so `backend/Dockerfile` must copy the whole + `internal/` tree, not just `*.go`. Sessions stateless + HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, and when empty, + web routes not registered at all. UI mutations read-modify-write + through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one + place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`. + **Design-tool caveat:** templates link `/static/style.css` root-absolutely + (correct — served from `/`), but impeccable detector resolves + stylesheet href with `path.resolve(fileDir, href)`, drops directory + on leading `/` and silently skip file. Relative href don't help + either: template's directory isn't its served path. So + `detect.mjs backend/internal/web/templates` reports **false clean** — + always pass `backend/internal/web/static` too. One finding there, + `overused-font` on "Instrument Serif", deliberate identity choice, not debt. +- **Every action that moves series out of list is confirm-gated.** + Archive, finish, remove each open own `.confirm-row` disclosure + (`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈ + `archive|finish|remove`); restore fire instantly since it's the reversal. + Remove's row wear ember wash, two reversible ones wear `.calm` grey. + `--ember` stay reserved for new-chapter signal: busy bar and inline + error use `--mute`. +- **Latest-chapter poller:** ticker goroutine in same binary re-check + each bookmarked series' newest published chapter from backend's own + network access, so `latest_chapter` stay fresh when user not + browsing. Second, parallel signal — userscript keep own + `maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged. + Two independent clocks: per-bookmark cooldown (`latest_checked_at` column, + enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval. + Row stamped *before* fetch so broken series wait out full + cooldown instead of retrying every tick, and writes go through + `Store.Get` + `Store.Upsert` so new chapter never reorders list. + Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth + against fingerprint-based blocking; any failure log and skip. kagane and + novelfull sit behind Cloudflare JavaScript challenges the TLS client can't + clear, so they are browser-only: fetched over CDP via `BROWSER_WS_URL`, and + simply not polled when that's unset. See + `docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`. + Poller's `Store.Get` + `Store.Upsert` not wrapped in transaction, so + userscript `PUT` that commits between the two can get overwritten by + poller's stale re-read — reverting that read progress and, since stored + value now differs, moving `updated_at` and reordering list. Known, + accepted limitation for single-user deployment, not bug to fix. +- **`updated_at` drives list order, so moves only on real reading progress:** server apply its timestamp when row new or `last_chapter_num` changes, else keep stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**, clients must adopt that response rather than own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4. +- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` | + `finished`, orthogonal to `favorite`. Archived and finished appear only in + own tab — not in All, Updated, Favourites, or recent strip. Poller keeps + checking archived series and skip finished ones. `finished` settable + only from web UI; `PUT /bookmarks/{key}` reject it with 400. + **Empty incoming status means "keep stored one"** — resolved on the + `VALUES` side of `Store.Upsert`, not conflict clause, since + `excluded.*` is post-evaluation row and default applied there would + wipe bucket on every PUT from client that predates column. See + `docs/superpowers/specs/2026-07-27-status-buckets-design.md`. +- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH` + (default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD` + (gates browser UI; unset disable it), + `LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` + (background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`). + `USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`, + default `/userscript/manga-bookmark.user.js`, supplied by bindmount). + `BROWSER_WS_URL` (headless-shell CDP endpoint for kagane and novelfull; + unset disables browser polling and leaves those sites to the userscript + alone). diff --git a/backend/api_test.go b/backend/api_test.go index 19e9450..fafc1fa 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "mangabm/backend/internal/store" + "bookmarkmanager/backend/internal/store" ) const testToken = "s3cret-token" @@ -470,3 +470,40 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) { t.Fatalf("status = %d, want 200", rr.Code) } } + +// Both scripts are served from the same handler on the same token, outside the +// WEB_PASSWORD gate — a wrong token is a 404, never a 401. +func TestNovelUserscriptServed(t *testing.T) { + dir := t.TempDir() + novelPath := filepath.Join(dir, "novel-bookmark.user.js") + if err := os.WriteFile(novelPath, []byte("// novel\n"), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + + s, err := store.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + + cfg := testConfig() + cfg.NovelUserscriptPath = novelPath + srv := newRouter(s, cfg) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, + "/u/"+testToken+"/novel-bookmark.user.js", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { + t.Fatalf("Content-Type = %q, want text/javascript", ct) + } + + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, + "/u/wrong-token/novel-bookmark.user.js", nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("wrong token status = %d, want 404", rr.Code) + } +} diff --git a/backend/go.mod b/backend/go.mod index f4fb0e1..43db272 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,4 +1,4 @@ -module mangabm/backend +module bookmarkmanager/backend go 1.26 diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index aa6447c..597cfbb 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "mangabm/backend/internal/store" + "bookmarkmanager/backend/internal/store" ) // Handler serves the userscript-facing JSON bookmark API. @@ -78,6 +78,15 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) { return } + // Same rule as status: empty means "keep the stored value". An unknown + // value is a client bug, not something to silently coerce to manga. + switch b.Kind { + case "", store.KindManga, store.KindNovel: + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid kind"}) + return + } + // Candidate timestamp, not a decision: Upsert keeps the stored one unless // reading progress actually moved. Any client value is ignored. b.UpdatedAt = time.Now().UnixMilli() diff --git a/backend/internal/latest/browser.go b/backend/internal/latest/browser.go index 10bcf21..fb856af 100644 --- a/backend/internal/latest/browser.go +++ b/backend/internal/latest/browser.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "regexp" + "strings" "sync" "time" @@ -24,18 +25,23 @@ var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`) // BrowserFetcher retrieves pages through a remote headless Chrome over the // DevTools Protocol. // -// It exists for one reason: kagane.to sits behind a Cloudflare JavaScript -// challenge. Verified 2026-08-03 from the deployment host, plain HTTP and -// bogdanfinn/tls-client with a Chrome_133 profile both get 403 with -// cf-mitigated: challenge on every path, including the API, robots.txt and -// images. Clearing it requires executing the challenge script, which only a -// real browser does. +// It exists for one reason: kagane.to and novelfull.com sit behind a +// Cloudflare JavaScript challenge. Verified 2026-08-03 (kagane) and 2026-08-05 +// (novelfull) from the deployment host, plain HTTP and bogdanfinn/tls-client +// with a Chrome_133 profile both get 403 with cf-mitigated: challenge on every +// path, including the API, robots.txt and images. Clearing it requires +// executing the challenge script, which only a real browser does. // // The request is made *inside* the page rather than by extracting cf_clearance // and replaying it through TLSFetcher. That cookie is bound to IP, User-Agent // and often the TLS fingerprint, so replaying it means keeping three things in // sync that break silently and separately. The browser's own cookie jar // persists across polls, so the challenge is solved once every few hours. +// +// The two sites differ in how the chapter list is read: kagane serves it from +// a JSON API that must be called from inside the page (so the request carries +// the clearance cookie), while novelfull renders it into the HTML so the +// cleared DOM is the payload. type BrowserFetcher struct { allocCtx context.Context cancel context.CancelFunc @@ -73,14 +79,16 @@ func (f *BrowserFetcher) Close() { f.cancel() } -// Get navigates to seriesURL, lets any challenge resolve, then reads the site's -// JSON API from inside the page so the request carries the clearance cookie. -// The returned body is API JSON, which is what latestChapterFrom's kagane case -// expects — it is not HTML. +// Get navigates to seriesURL, lets any challenge resolve, then reads either the +// site's JSON API (kagane) from inside the page so the request carries the +// clearance cookie, or the served HTML itself (novelfull) — see +// novelfullSeriesURL for the latter case. The returned body is whatever the +// site's chapter list lives in, which is what latestChapterFrom's per-site +// switch expects. func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int, error) { - apiURL, ok := kaganeAPIURL(seriesURL) - if !ok { - return "", 0, fmt.Errorf("not a fetchable kagane series url: %q", seriesURL) + apiURL, isKagane := kaganeAPIURL(seriesURL) + if !isKagane && !novelfullSeriesURL(seriesURL) { + return "", 0, fmt.Errorf("not a fetchable browser series url: %q", seriesURL) } f.mu.Lock() @@ -101,16 +109,26 @@ func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int }() var body string + // kagane's chapter list is only in its JSON API, which must be called from + // inside the page so the request carries the clearance cookie. novelfull + // renders its chapters into the HTML, so the cleared DOM is the answer. + // chromedp.OuterHTML returns a QueryAction and chromedp.Evaluate an + // EvaluateAction, so the variable has to be the interface both implement. + var read chromedp.Action = chromedp.OuterHTML("html", &body, chromedp.ByQuery) + if isKagane { + read = chromedp.Evaluate( + `fetch(`+jsString(apiURL)+`).then(r => r.ok ? r.text() : "")`, + &body, + awaitPromise, + ) + } + err := chromedp.Run(tabCtx, chromedp.Navigate(seriesURL), // The challenge reloads the page itself when it passes; waiting for the // site's own root element is what tells us we are through it. chromedp.WaitReady("body", chromedp.ByQuery), - chromedp.Evaluate( - `fetch(`+jsString(apiURL)+`).then(r => r.ok ? r.text() : "")`, - &body, - awaitPromise, - ), + read, ) if err != nil { return "", 0, fmt.Errorf("browser fetch %q: %w", seriesURL, err) @@ -139,6 +157,18 @@ func kaganeAPIURL(seriesURL string) (string, bool) { return "https://kagane.to/api/v2/series/" + m[1], true } +// novelfullSeriesURL reports whether seriesURL is a novelfull series page this +// fetcher will open. novelfull's chapter list is in the served HTML, so unlike +// kagane there is no API to call from inside the page — the challenge-cleared +// DOM is the payload. The host is pinned here for the same reason kagane's is: +// series_url is client-supplied and a headless browser is a strong SSRF +// primitive. +func novelfullSeriesURL(seriesURL string) bool { + u, err := url.Parse(seriesURL) + return err == nil && u.Scheme == "https" && u.Hostname() == "novelfull.com" && + strings.HasSuffix(u.Path, ".html") +} + // awaitPromise makes Evaluate resolve the promise rather than returning a // serialised Promise object. func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { diff --git a/backend/internal/latest/browser_test.go b/backend/internal/latest/browser_test.go index fa91a3a..c94c1f4 100644 --- a/backend/internal/latest/browser_test.go +++ b/backend/internal/latest/browser_test.go @@ -36,3 +36,24 @@ func TestKaganeAPIURL(t *testing.T) { }) } } + +func TestNovelfullSeriesURL(t *testing.T) { + cases := []struct { + name string + url string + want bool + }{ + {"series page", "https://novelfull.com/reverend-insanity.html", true}, + {"foreign host", "https://evil.example/reverend-insanity.html", false}, + {"not https", "http://novelfull.com/reverend-insanity.html", false}, + {"not a series page", "https://novelfull.com/genre/Fantasy", false}, + {"garbage", "://nope", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := novelfullSeriesURL(tc.url); got != tc.want { + t.Fatalf("novelfullSeriesURL(%q) = %v, want %v", tc.url, got, tc.want) + } + }) + } +} diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index f5770d9..57e764f 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -6,7 +6,7 @@ import ( "net/url" "time" - "mangabm/backend/internal/store" + "bookmarkmanager/backend/internal/store" ) // Fetcher retrieves a series page. It exists as an interface so tests can inject @@ -44,12 +44,13 @@ type Poller struct { } // fetcherFor returns the fetcher a site needs, or nil when the site cannot be -// fetched at all right now. kagane sits behind a Cloudflare JavaScript -// challenge that no TLS fingerprint clears — verified 2026-08-03 from the -// deployment host with the same Chrome profile TLSFetcher uses — so it is -// browser-only or nothing. +// fetched at all right now. kagane and novelfull both sit behind a Cloudflare +// JavaScript challenge that no TLS fingerprint clears — kagane verified +// 2026-08-03, novelfull verified 2026-08-05, both against the same Chrome_133 +// profile TLSFetcher uses — so they are browser-only or nothing. func (p *Poller) fetcherFor(site string) Fetcher { - if site == "kagane" { + switch site { + case "kagane", "novelfull": return p.BrowserFetch } return p.Fetch @@ -212,13 +213,18 @@ func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) { // a defence against the poller being used to probe arbitrary hosts from the // server's own network position, not just a check against wasted requests. // -// kagane is held to a stricter rule: it is fetched by a headless browser, which -// executes JavaScript and carries cookies, and is therefore a far stronger SSRF -// primitive than an HTTP GET. Its host must match exactly, not merely be -// non-empty. +// Three sites are held to a stricter rule, each for a different reason: +// +// - kagane and novelfull are fetched by a headless browser, which executes +// JavaScript and carries cookies, and is therefore a far stronger SSRF +// primitive than an HTTP GET. Their hosts must match exactly, not merely +// be non-empty. +// - lightnovelworld's parser regex hardcodes its host, so a URL anywhere +// else could never yield a match — reject it here rather than burn the +// request. func fetchableSeriesURL(site, seriesURL string) bool { switch site { - case "asura", "demonic", "comix", "kagane": + case "asura", "demonic", "comix", "kagane", "novelfull", "lightnovelworld": default: return false } @@ -229,8 +235,17 @@ func fetchableSeriesURL(site, seriesURL string) bool { if u.Scheme != "https" || u.Host == "" { return false } - if site == "kagane" { + switch site { + case "kagane": return u.Hostname() == "kagane.to" + case "novelfull": + // Fetched by a real browser, same as kagane, so the host is pinned + // rather than merely non-empty. + return u.Hostname() == "novelfull.com" + case "lightnovelworld": + // Its parser regex hardcodes this host, so a URL anywhere else could + // never yield a match — reject it here rather than burn the request. + return u.Hostname() == "lightnovelworld.net" } return true } diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go index a960307..fdb9985 100644 --- a/backend/internal/latest/poller_test.go +++ b/backend/internal/latest/poller_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "mangabm/backend/internal/store" + "bookmarkmanager/backend/internal/store" ) // newTestStore opens a fresh SQLite store in a temp dir. @@ -453,3 +453,49 @@ func TestKaganeUsesBrowserFetcher(t *testing.T) { t.Errorf("LatestChapterNum = %v, want 41", got.LatestChapterNum) } } + +func TestFetcherForRoutesNovelSites(t *testing.T) { + tls := &fakeFetcher{} + browser := &fakeFetcher{} + p := &Poller{Fetch: tls, BrowserFetch: browser} + + cases := []struct { + site string + want Fetcher + }{ + {"asura", tls}, + {"lightnovelworld", tls}, + {"kagane", browser}, + {"novelfull", browser}, + } + for _, tc := range cases { + t.Run(tc.site, func(t *testing.T) { + if got := p.fetcherFor(tc.site); got != tc.want { + t.Fatalf("fetcherFor(%q) = %v, want %v", tc.site, got, tc.want) + } + }) + } +} + +func TestFetchableSeriesURLPinsNovelHosts(t *testing.T) { + cases := []struct { + name string + site string + url string + want bool + }{ + {"novelfull on its own host", "novelfull", "https://novelfull.com/reverend-insanity.html", true}, + {"novelfull on a foreign host", "novelfull", "https://evil.example/x.html", false}, + {"novelfull over http", "novelfull", "http://novelfull.com/x.html", false}, + {"lightnovelworld on its own host", "lightnovelworld", "https://lightnovelworld.net/novel/a-will-eternal/", true}, + {"lightnovelworld on a foreign host", "lightnovelworld", "https://evil.example/novel/x/", false}, + {"unknown site", "webnovel", "https://webnovel.com/x", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := fetchableSeriesURL(tc.site, tc.url); got != tc.want { + t.Fatalf("fetchableSeriesURL(%q, %q) = %v, want %v", tc.site, tc.url, got, tc.want) + } + }) + } +} diff --git a/backend/internal/latest/sites.go b/backend/internal/latest/sites.go index fcc771b..78d14e0 100644 --- a/backend/internal/latest/sites.go +++ b/backend/internal/latest/sites.go @@ -1,11 +1,12 @@ package latest import ( + "net/url" "regexp" "strconv" "strings" - "mangabm/backend/internal/store" + "bookmarkmanager/backend/internal/store" ) // latestChapter is the newest chapter a series page advertises. @@ -34,6 +35,17 @@ var comixSlugRe = regexp.MustCompile(`/title/([^/?#]+)`) // there are no anchors to scan. var kaganeChapterRe = regexp.MustCompile(`"chapter_no":"([0-9.]+)"`) +// novelfullSlugRe pulls the series slug out of a stored series_url. novelfull +// series pages are "/.html"; their chapter anchors are +// "//chapter-[-].html". Verified live 2026-08-05. +var novelfullSlugRe = regexp.MustCompile(`^/([^/?#]+)\.html$`) + +// lnwSlugRe does the same for lightnovelworld, whose series pages live under +// /novel// while its chapter URLs are flat at the site root: +// "/-chapter-/", absolute in the page's own anchors. Verified live +// 2026-08-05. +var lnwSlugRe = regexp.MustCompile(`^/novel/([^/?#]+)/?$`) + // latestChapterFrom returns the highest chapter number body advertises for this // series. ok is false when the body yields nothing usable — an unknown site, an // empty body, a Cloudflare challenge page, and a site redesign all land here, @@ -86,6 +98,29 @@ func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) { re = regexp.MustCompile(`"latestChapterUrl":"/title/` + regexp.QuoteMeta(id) + `-[^"]*-chapter-([0-9.]+)"`) case "kagane": re = kaganeChapterRe + case "novelfull": + u, err := url.Parse(seriesURL) + if err != nil { + return latestChapter{}, false + } + m := novelfullSlugRe.FindStringSubmatch(u.Path) + if m == nil { + return latestChapter{}, false + } + // Scoped to this series' slug for the same reason asura is: page 1 + // carries a "latest chapters" widget and a "you may also like" strip, + // and neither may contribute to the maximum. + re = regexp.MustCompile(`/` + regexp.QuoteMeta(m[1]) + `/chapter-([0-9.]+)`) + case "lightnovelworld": + u, err := url.Parse(seriesURL) + if err != nil { + return latestChapter{}, false + } + m := lnwSlugRe.FindStringSubmatch(u.Path) + if m == nil { + return latestChapter{}, false + } + re = regexp.MustCompile(`lightnovelworld\.net/` + regexp.QuoteMeta(m[1]) + `-chapter-([0-9.]+)/`) default: return latestChapter{}, false } diff --git a/backend/internal/latest/sites_test.go b/backend/internal/latest/sites_test.go index a2bb82d..49423ad 100644 --- a/backend/internal/latest/sites_test.go +++ b/backend/internal/latest/sites_test.go @@ -52,6 +52,33 @@ const kaganeAPIFixture = ` {"book_id":"c","title":"Episode 40.5","chapter_no":"40.5","sort_no":40}]} ` +// Trimmed from https://novelfull.com/reverend-insanity.html fetched 2026-08-05. +// The page carries a newest-first "latest chapters" widget above an +// oldest-first paginated list, so the newest anchor is deliberately NOT last — +// only a maximum finds it. The final anchor belongs to another series and must +// be excluded by slug scoping. +const novelfullSeriesFixture = ` + + +Chapter 9999 +` + +// Trimmed from https://lightnovelworld.net/novel/a-will-eternal/ fetched +// 2026-08-05. Its chapter anchors are absolute and flat — /-chapter-/ +// at the site root, not under /novel/. The last anchor is another series'. +const lnwSeriesFixture = ` +Chapter 1 +Chapter 1317 +Chapter 1298 +Chapter 9999 +` + func TestLatestChapterFrom(t *testing.T) { const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af" const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer" @@ -146,6 +173,48 @@ func TestLatestChapterFrom(t *testing.T) { body: challengeFixture, wantOK: false, }, + { + name: "novelfull takes the max and ignores another series", + site: "novelfull", + seriesURL: "https://novelfull.com/reverend-insanity.html", + body: novelfullSeriesFixture, + wantOK: true, wantNum: 2334, wantLabel: "Chapter 2334", + }, + { + name: "novelfull yields nothing on a challenge page", + site: "novelfull", + seriesURL: "https://novelfull.com/reverend-insanity.html", + body: challengeFixture, + wantOK: false, + }, + { + name: "novelfull with an unparseable series url", + site: "novelfull", + seriesURL: "https://novelfull.com/genre/Fantasy", + body: novelfullSeriesFixture, + wantOK: false, + }, + { + name: "lightnovelworld takes the max and ignores another series", + site: "lightnovelworld", + seriesURL: "https://lightnovelworld.net/novel/a-will-eternal/", + body: lnwSeriesFixture, + wantOK: true, wantNum: 1317, wantLabel: "Chapter 1317", + }, + { + name: "lightnovelworld tolerates a series url with no trailing slash", + site: "lightnovelworld", + seriesURL: "https://lightnovelworld.net/novel/a-will-eternal", + body: lnwSeriesFixture, + wantOK: true, wantNum: 1317, wantLabel: "Chapter 1317", + }, + { + name: "lightnovelworld yields nothing on a challenge page", + site: "lightnovelworld", + seriesURL: "https://lightnovelworld.net/novel/a-will-eternal/", + body: challengeFixture, + wantOK: false, + }, } for _, tt := range tests { diff --git a/backend/internal/session/session.go b/backend/internal/session/session.go index 3c70592..77c5415 100644 --- a/backend/internal/session/session.go +++ b/backend/internal/session/session.go @@ -14,13 +14,13 @@ import ( ) const ( - CookieName = "mangabm_session" + CookieName = "bmgr_session" // 60 days: long enough that a phone stays logged in between reading spells. sessionTTL = 60 * 24 * time.Hour // Domain separation, so the session key can never collide with any other // use of the secrets it is derived from. Changing this string logs // everyone out. - sessionKeyPurpose = "mangabm-web-session-v1" + sessionKeyPurpose = "bmgr-web-session-v1" ) // Key derives the cookie-signing key from both secrets. Sessions are diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 0658d2b..903e66a 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -33,6 +33,9 @@ type Bookmark struct { // Archived series stay polled for new chapters; finished ones do not. // Empty on the way in means "no opinion" — see Upsert. Status string `json:"status"` + // Kind is the library bucket: manga or novel. Empty on the way in means + // "no opinion" — see Upsert. + Kind string `json:"kind"` } // HasNewChapter reports whether the site has published past the read point. @@ -96,6 +99,14 @@ func (b Bookmark) Initial() string { return "?" } +// Library buckets. A bookmark is in exactly one. This cannot be derived from +// Site: asurascans serves manga and novels from the same /comics/ path, so the +// userscript that recorded the page is the only party that knows which. +const ( + KindManga = "manga" + KindNovel = "novel" +) + // Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal. const ( StatusReading = "reading" @@ -119,6 +130,7 @@ CREATE TABLE IF NOT EXISTS bookmarks ( latest_chapter_num REAL, latest_checked_at INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'reading', + kind TEXT NOT NULL DEFAULT 'manga', updated_at INTEGER NOT NULL );` @@ -135,11 +147,14 @@ var addedColumns = []struct{ name, ddl string }{ // Lifecycle bucket. The DEFAULT backfills every pre-existing row as // 'reading', so there is no separate migration step. {"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`}, + // Library bucket. The DEFAULT backfills every pre-existing row as 'manga', + // which is what every row written before novels existed actually is. + {"kind", `ALTER TABLE bookmarks ADD COLUMN kind TEXT NOT NULL DEFAULT 'manga'`}, } const bookmarkColumns = `key, site, series_id, title, series_url, cover, last_chapter, last_chapter_num, last_chapter_url, - favorite, latest_chapter, latest_chapter_num, updated_at, status` + favorite, latest_chapter, latest_chapter_num, updated_at, status, kind` // Store is the SQLite-backed bookmark store. type Store struct { @@ -296,7 +311,7 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) { if err := scan( &b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover, &lastChapter, &lastChapterNum, &lastChapterURL, - &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, + &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, &b.Kind, ); err != nil { return Bookmark{}, err } @@ -384,18 +399,20 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) { // is the stored row and excluded.* is the incoming one; a brand-new key // never reaches this clause, so it keeps the fresh timestamp from VALUES. // - // The status column resolves on the VALUES side, not in the conflict - // clause: excluded.* is the row *after* these expressions are evaluated, - // so a default applied there would look identical to a real 'reading' and - // would overwrite an archived row on every PUT from a client that knows - // nothing about the column. Resolved once here, an empty incoming status - // means "keep what is stored", and only a brand-new row falls through to - // the literal default. The subquery runs inside this transaction, so it - // sees the row this statement is about to conflict with. + // The status and kind columns resolve on the VALUES side, not in the + // conflict clause: excluded.* is the row *after* these expressions are + // evaluated, so a default applied there would look identical to a real + // 'reading' / 'manga' and would overwrite an archived or novel row on + // every PUT from a client that knows nothing about the column. Resolved + // once here, an empty incoming status or kind means "keep what is + // stored", and only a brand-new row falls through to the literal + // default. The subquery runs inside this transaction, so it sees the + // row this statement is about to conflict with. if _, err := tx.Exec(` INSERT INTO bookmarks (`+bookmarkColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')) + COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading'), + COALESCE(NULLIF(?, ''), (SELECT kind FROM bookmarks WHERE key = ?), 'manga')) ON CONFLICT(key) DO UPDATE SET site=excluded.site, series_id=excluded.series_id, title=excluded.title, series_url=excluded.series_url, cover=excluded.cover, @@ -405,6 +422,7 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) { latest_chapter=excluded.latest_chapter, latest_chapter_num=excluded.latest_chapter_num, status=excluded.status, + kind=excluded.kind, updated_at=CASE WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num THEN excluded.updated_at @@ -413,7 +431,8 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) { b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, b.LastChapter, b.LastChapterNum, b.LastChapterURL, b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt, - b.Status, b.Key); err != nil { + b.Status, b.Key, + b.Kind, b.Key); err != nil { return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err) } diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 0653b01..7a29188 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -660,3 +660,104 @@ func TestDisplayChapter(t *testing.T) { t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11") } } + +func TestUpsertKindDefaultsToManga(t *testing.T) { + store := newTestStore(t) + got, err := store.Upsert(Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000, + }) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if got.Kind != KindManga { + t.Fatalf("Kind = %q, want %q", got.Kind, KindManga) + } +} + +func TestUpsertKindRoundTrips(t *testing.T) { + store := newTestStore(t) + got, err := store.Upsert(Bookmark{ + Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld", + SeriesID: "a-will-eternal", Kind: KindNovel, UpdatedAt: 1000, + }) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if got.Kind != KindNovel { + t.Fatalf("Kind = %q, want %q", got.Kind, KindNovel) + } +} + +// The real hazard: a client that predates the column sends no kind at all. That +// must keep the stored library, not silently demote a novel to manga. +func TestUpsertEmptyKindKeepsStoredValue(t *testing.T) { + store := newTestStore(t) + if _, err := store.Upsert(Bookmark{ + Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld", + SeriesID: "a-will-eternal", Kind: KindNovel, LastChapterNum: 10, UpdatedAt: 1000, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + got, err := store.Upsert(Bookmark{ + Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld", + SeriesID: "a-will-eternal", Kind: "", LastChapterNum: 11, UpdatedAt: 2000, + }) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if got.Kind != KindNovel { + t.Fatalf("Kind = %q, want %q — an empty kind must not reset the library", got.Kind, KindNovel) + } + if got.LastChapterNum != 11 { + t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum) + } +} + +// A database created before this column exists must gain it, backfilled as +// manga, without losing anything. +func TestLegacyDatabaseGainsKindAsManga(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + + legacy, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open legacy db: %v", err) + } + if _, err := legacy.Exec(` + CREATE TABLE bookmarks ( + key TEXT PRIMARY KEY, + site TEXT NOT NULL, + series_id TEXT NOT NULL, + title TEXT, + series_url TEXT, + cover TEXT, + last_chapter TEXT, + last_chapter_num REAL, + last_chapter_url TEXT, + updated_at INTEGER NOT NULL + )`); err != nil { + t.Fatalf("create legacy schema: %v", err) + } + if _, err := legacy.Exec(` + INSERT INTO bookmarks (key, site, series_id, title, updated_at) + VALUES ('asura:legacy', 'asura', 'legacy', 'Legacy Series', 123)`); err != nil { + t.Fatalf("seed legacy row: %v", err) + } + if err := legacy.Close(); err != nil { + t.Fatalf("close legacy db: %v", err) + } + + store, err := Open(dbPath) + if err != nil { + t.Fatalf("Open on legacy db: %v", err) + } + t.Cleanup(func() { store.Close() }) + + list, err := store.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 || list[0].Kind != KindManga { + t.Fatalf("legacy row should backfill as manga, got %+v", list) + } +} diff --git a/backend/internal/web/static/filter.js b/backend/internal/web/static/filter.js index 40bbb47..baedcbd 100644 --- a/backend/internal/web/static/filter.js +++ b/backend/internal/web/static/filter.js @@ -46,7 +46,7 @@ // htmx replaces the list on a tab switch, so re-apply to the new cards. document.body.addEventListener("htmx:afterSwap", applyFilter); - document.addEventListener("mangabm:refilter", applyFilter); + document.addEventListener("bmgr:refilter", applyFilter); })(); function setActiveTab(el) { @@ -58,7 +58,7 @@ function setActiveTab(el) { }); // The strip is outside the swapped region, so its visibility is re-decided // here rather than by the server that just answered. - document.dispatchEvent(new Event("mangabm:refilter")); + document.dispatchEvent(new Event("bmgr:refilter")); } // The chapter-edit form and the archive/finish/remove confirm rows are the diff --git a/backend/internal/web/static/login-art.png b/backend/internal/web/static/login-art.png new file mode 100644 index 0000000..d957714 Binary files /dev/null and b/backend/internal/web/static/login-art.png differ diff --git a/backend/internal/web/static/logo.svg b/backend/internal/web/static/logo.svg index 8571fba..6034eb4 100644 --- a/backend/internal/web/static/logo.svg +++ b/backend/internal/web/static/logo.svg @@ -1,5 +1,5 @@ - - mangaBookmark + + BookmarkManager diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css index 6b6a9fe..d5e5756 100644 --- a/backend/internal/web/static/style.css +++ b/backend/internal/web/static/style.css @@ -97,6 +97,10 @@ --comix: #8a9a7d; --kagane: #9a8aa5; + /* novel sources: same muted family, two hues the manga sites do not use */ + --novelfull: #a59a7d; + --lightnovelworld: #7da59a; + /* Covers are often missing; the hatch keeps the slot honest instead of faking artwork. */ --hatch: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px); @@ -150,6 +154,8 @@ --demonic: #8a6a55; --comix: #5f7250; --kagane: #6f5f7d; + --novelfull: #7d6f4f; + --lightnovelworld: #4f7d70; --hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px); --hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px); @@ -485,12 +491,51 @@ button { cursor: pointer; } .site-demonic { color: var(--demonic); } .site-comix { color: var(--comix); } .site-kagane { color: var(--kagane); } +.site-novelfull { color: var(--novelfull); } +.site-lightnovelworld { color: var(--lightnovelworld); } .new-chapter { color: var(--ember); } .state { display: flex; align-items: center; gap: 4px; color: var(--mute); } .state svg { width: 10px; height: 10px; } .is-dim .meta { color: var(--mute-2); } .is-dim .site-asura, .is-dim .site-demonic, -.is-dim .site-comix, .is-dim .site-kagane { color: var(--mute); filter: grayscale(.6); } +.is-dim .site-comix, .is-dim .site-kagane, +.is-dim .site-novelfull, .is-dim .site-lightnovelworld { color: var(--mute); filter: grayscale(.6); } + +/* ---- library switch: manga and novels are separate libraries, so the pair + sits in the topbar next to the wordmark rather than among the buckets. ---- */ +.libswitch { + display: flex; + margin-left: auto; + border: 1px solid var(--field-line); +} +.libswitch a { + padding: 7px 13px; + font: 500 10px/1 var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; + color: var(--mute); + text-decoration: none; +} +.libswitch a + a { border-left: 1px solid var(--field-line); } +.libswitch a:hover { color: var(--paper-dim); } +/* The library you are in carries the ember, the same heat the wordmark and the + Updated tab use — it is the one piece of chrome that has to be unmistakable. */ +.libswitch a.active { + background: var(--ember-wash); + color: var(--ember); + box-shadow: inset 0 -2px 0 var(--ember); +} +.topbar form { margin-left: 18px; } +/* At phone width brand + switch + Log out do not fit on one line, so the + switch takes its own row under the wordmark rather than pushing Log out + off-screen. */ +@media (max-width: 719px) { + .topbar { flex-wrap: wrap; row-gap: 12px; } + .brand { flex: 1 1 auto; min-width: 0; } + .libswitch { order: 3; margin-left: 0; } + .libswitch a { flex: 1; text-align: center; padding: 8px 14px; } + .topbar form { margin-left: 12px; } +} /* ---- action strip: full-width on a phone, hairline-divided cells ---- */ .actions { @@ -714,6 +759,20 @@ button { cursor: pointer; } color: var(--paper); } .login-card h1 em { color: var(--ember); font-style: italic; } +.login-art { + margin: 8px auto 0; + width: 240px; + aspect-ratio: 1; + display: grid; + place-items: center; + background: radial-gradient(circle, var(--ember-wash) 0%, transparent 70%); +} +.login-art img { + width: 100%; + height: 100%; + object-fit: contain; + filter: drop-shadow(0 0 34px var(--ember-wash)) drop-shadow(0 18px 24px rgba(0,0,0,.5)); +} .login-card form { display: flex; flex-direction: column; gap: 18px; } .login-card label { font: 500 10px/1 var(--font-mono); @@ -754,12 +813,22 @@ button { cursor: pointer; } color: #fff; } +/* ---- laptop and up: the whole sheet is drawn 20% larger, which is what + reading it at 120% zoom on a 1920-wide screen was doing by hand. Everything + in this file is sized in px, so scaling the root is the one adjustment that + keeps every proportion — hairlines, cover ratios, hit targets — + intact. ---- */ +@media (min-width: 1280px) { + :root { zoom: 1.2; } +} + /* ---- desktop: same measure, actions fold up beside the row ---- */ @media (min-width: 720px) { :root { --cover-w: 80px; --row-gap: 20px; } .topbar { padding: 26px 32px 18px; } .brand { font-size: 30px; } .brand .mark { width: 35px; height: 30px; } + .libswitch a { padding: 9px 16px; font-size: 11px; } .chrome { flex-direction: row; diff --git a/backend/internal/web/templates/app.html b/backend/internal/web/templates/app.html index 10d5342..760c0cd 100644 --- a/backend/internal/web/templates/app.html +++ b/backend/internal/web/templates/app.html @@ -5,7 +5,7 @@ - mangaBookmark + BookmarkManager @@ -19,7 +19,15 @@ {{template "icons" .}}
-

{{template "mark" .}}mangaBookmark

+

{{template "mark" .}}BookmarkManager

+ {{/* Plain full-page links, not htmx swaps: switching library replaces the + tab row and the chrome, which is a page, not a fragment. */}} +
@@ -38,27 +46,31 @@ navigation, not an ARIA tablist — aria-current carries "which bucket am I in" without owing a tabpanel contract we do not implement. */}}
diff --git a/backend/internal/web/templates/login.html b/backend/internal/web/templates/login.html index 1542737..781ce81 100644 --- a/backend/internal/web/templates/login.html +++ b/backend/internal/web/templates/login.html @@ -5,7 +5,7 @@ - mangaBookmark + BookmarkManager @@ -14,8 +14,11 @@
Private library -

{{template "mark" .}}mangaBookmark

+

{{template "mark" .}}BookmarkManager

+
diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go index 6e8b36e..732afe7 100644 --- a/backend/internal/web/web.go +++ b/backend/internal/web/web.go @@ -14,8 +14,8 @@ import ( "strings" "time" - "mangabm/backend/internal/session" - "mangabm/backend/internal/store" + "bookmarkmanager/backend/internal/session" + "bookmarkmanager/backend/internal/store" ) //go:embed templates @@ -40,6 +40,10 @@ type Handler struct { // listView is what every list-rendering template receives. type listView struct { + // Lib is the library this view renders: store.KindManga or store.KindNovel. + // Manga is the default and carries no query parameter, so every pre-novel + // URL keeps meaning exactly what it did. + Lib string Tab string // "all", "fav", or "new" Recent []store.Bookmark Items []store.Bookmark @@ -53,6 +57,23 @@ type listView struct { OOB bool } +// PageURL and ListURL are the two link shapes every tab needs. Building them +// here rather than concatenating in the template is what keeps the library +// parameter from being dropped on one link out of ten. +func (v listView) PageURL(tab string) string { + if v.Lib == store.KindNovel { + return "/?lib=novel&tab=" + tab + } + return "/?tab=" + tab +} + +func (v listView) ListURL(tab string) string { + if v.Lib == store.KindNovel { + return "/ui/list?lib=novel&tab=" + tab + } + return "/ui/list?tab=" + tab +} + // loginView is what the login template receives. type loginView struct { Error string @@ -146,7 +167,7 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) { h.render(w, http.StatusOK, "login", loginView{}) return } - view, err := h.buildListView(r.URL.Query().Get("tab")) + view, err := h.buildListView(libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab")) if err != nil { log.Printf("index: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -167,6 +188,26 @@ func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []sto return out } +// kindOf reads a bookmark's library. A row cached or written before the kind +// column existed has none; every one of those is manga, which is what the +// column default says too. +func kindOf(b store.Bookmark) string { + if b.Kind == "" { + return store.KindManga + } + return b.Kind +} + +// libOf normalises the query parameter. Anything that is not the novel library +// is the manga one, so a typo lands on the default page rather than an empty +// list. +func libOf(q string) string { + if q == store.KindNovel { + return store.KindNovel + } + return store.KindManga +} + // buildListView loads the list once and derives both the tab-filtered items and // the recent strip from it. // @@ -174,11 +215,20 @@ func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []sto // in All, not in Updated, not in Favourites, and not in the recent strip. An // archived favourite therefore shows only under Archived: Favourites means // "favourites I am currently reading". -func (h *Handler) buildListView(tab string) (listView, error) { +func (h *Handler) buildListView(lib, tab string) (listView, error) { all, err := h.store.List() // already ordered updated_at DESC if err != nil { return listView{}, err } + // Narrow to one library first: reading, withNew and recent all derive from + // this slice, so doing it later would let the other library's rows into the + // strip and the Updated badge. + all = filterBookmarks(all, func(b store.Bookmark) bool { return kindOf(b) == lib }) + + // Novels do not offer an Updated tab, so a hand-typed one lands on All. + if lib == store.KindNovel && tab == "new" { + tab = "all" + } reading := filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusReading }) withNew := filterBookmarks(reading, func(b store.Bookmark) bool { return b.HasNewChapter() }) @@ -213,11 +263,11 @@ func (h *Handler) buildListView(tab string) (listView, error) { recent = recent[:RecentCount] } } - return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil + return listView{Lib: lib, Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil } func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) { - view, err := h.buildListView(r.URL.Query().Get("tab")) + view, err := h.buildListView(libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab")) if err != nil { log.Printf("ui list: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -241,6 +291,17 @@ func currentTab(r *http.Request) string { return u.Query().Get("tab") } +// currentLib is the library the reader is looking at, read from htmx's own +// header for the same reason currentTab is: out-of-band chrome must be rebuilt +// for that view rather than for the default one. +func currentLib(r *http.Request) string { + u, err := url.Parse(r.Header.Get("HX-Current-URL")) + if err != nil { + return store.KindManga + } + return libOf(u.Query().Get("lib")) +} + // writeChromeOOB appends the regions that live outside #list — the recent // strip, the Updated badge and the action key — as out-of-band swaps, so a // mutation cannot leave them describing the library as it was before the tap. @@ -248,7 +309,11 @@ func currentTab(r *http.Request) string { // Archive for Restore. func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) { view.OOB = true - for _, name := range []string{"recent", "newcount", "keyrow"} { + names := []string{"recent", "keyrow"} + if view.Lib == store.KindManga { + names = append(names, "newcount") + } + for _, name := range names { if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil { // The card is already written; stale chrome beats a torn response. log.Printf("render %s oob: %v", name, err) @@ -260,7 +325,7 @@ func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) { // refreshChrome rebuilds the chrome for the reader's current tab after a // mutation and appends it to the response. func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) { - view, err := h.buildListView(currentTab(r)) + view, err := h.buildListView(currentLib(r), currentTab(r)) if err != nil { log.Printf("ui chrome: %v", err) return diff --git a/backend/main.go b/backend/main.go index a4241c3..275f5b8 100644 --- a/backend/main.go +++ b/backend/main.go @@ -12,12 +12,12 @@ import ( "syscall" "time" - "mangabm/backend/internal/api" - "mangabm/backend/internal/httpmw" - "mangabm/backend/internal/latest" - "mangabm/backend/internal/store" - "mangabm/backend/internal/userscript" - "mangabm/backend/internal/web" + "bookmarkmanager/backend/internal/api" + "bookmarkmanager/backend/internal/httpmw" + "bookmarkmanager/backend/internal/latest" + "bookmarkmanager/backend/internal/store" + "bookmarkmanager/backend/internal/userscript" + "bookmarkmanager/backend/internal/web" ) // Config holds all runtime settings, sourced from environment variables. @@ -31,6 +31,10 @@ type Config struct { // UserscriptPath is the file served at /u/{token}/manga-bookmark.user.js. // Supplied by a bindmount so the script can be edited without a rebuild. UserscriptPath string + // NovelUserscriptPath is the file served at + // /u/{token}/novel-bookmark.user.js. Same bindmount, second script: the + // two libraries are separate installs. + NovelUserscriptPath string // LatestPoll configures the background latest-chapter fetcher. LatestPoll LatestPoll } @@ -138,12 +142,13 @@ func loadLatestPoll() LatestPoll { func loadConfig() Config { c := Config{ - Token: os.Getenv("API_TOKEN"), - DBPath: envOr("DB_PATH", "/data/bookmarks.db"), - Port: envOr("PORT", "8080"), - WebPassword: os.Getenv("WEB_PASSWORD"), - UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"), - LatestPoll: loadLatestPoll(), + Token: os.Getenv("API_TOKEN"), + DBPath: envOr("DB_PATH", "/data/bookmarks.db"), + Port: envOr("PORT", "8080"), + WebPassword: os.Getenv("WEB_PASSWORD"), + UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"), + NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"), + LatestPoll: loadLatestPoll(), } for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") { if o = strings.TrimSpace(o); o != "" { @@ -164,6 +169,7 @@ func newRouter(s *store.Store, cfg Config) http.Handler { // outside the WEB_PASSWORD gate (the script must be installable either // way). The path segment carries the token instead. mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath)) + mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js", userscript.Handler(cfg.Token, cfg.NovelUserscriptPath)) h := &api.Handler{Store: s} protected := http.NewServeMux() diff --git a/backend/main_test.go b/backend/main_test.go index 66eaa29..cbc6d6a 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "mangabm/backend/internal/store" + "bookmarkmanager/backend/internal/store" ) func TestLoadLatestPollDefaults(t *testing.T) { @@ -243,3 +243,76 @@ func TestGzipCompressesTextNotFonts(t *testing.T) { t.Errorf("Content-Encoding without Accept-Encoding = %q, want empty", enc) } } + +func TestPutKindValidation(t *testing.T) { + cases := []struct { + name string + kind string + want int + }{ + {"empty is no opinion", "", http.StatusOK}, + {"manga", "manga", http.StatusOK}, + {"novel", "novel", http.StatusOK}, + {"garbage", "comic", http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := newTestServer(t) + body := fmt.Sprintf(`{"title":"Solo","kind":%q}`, tc.kind) + req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", + strings.NewReader(body))) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != tc.want { + t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String()) + } + if tc.want != http.StatusOK { + return + } + var got store.Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + want := tc.kind + if want == "" { + want = "manga" + } + if got.Kind != want { + t.Fatalf("stored kind = %q, want %q", got.Kind, want) + } + }) + } +} + +// The preserve path: a novel row re-PUT by a client that omits the field +// entirely must stay a novel and still record the progress it carried. +func TestPutOmittedKindPreservesNovelAndAppliesProgress(t *testing.T) { + srv := newTestServer(t) + const key = "/bookmarks/lightnovelworld:a-will-eternal" + + seed := auth(httptest.NewRequest(http.MethodPut, key, + strings.NewReader(`{"title":"A Will Eternal","kind":"novel","last_chapter_num":10}`))) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, seed) + if rr.Code != http.StatusOK { + t.Fatalf("seed status = %d, want 200 (%s)", rr.Code, rr.Body.String()) + } + + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, key, + strings.NewReader(`{"title":"A Will Eternal","last_chapter_num":11}`)))) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rr.Code, rr.Body.String()) + } + var got store.Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Kind != store.KindNovel { + t.Fatalf("Kind = %q, want novel", got.Kind) + } + if got.LastChapterNum != 11 { + t.Fatalf("LastChapterNum = %v, want 11", got.LastChapterNum) + } +} diff --git a/backend/web_test.go b/backend/web_test.go index 4a9a048..0dd662c 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -11,9 +11,9 @@ import ( "testing" "time" - "mangabm/backend/internal/session" - "mangabm/backend/internal/store" - "mangabm/backend/internal/web" + "bookmarkmanager/backend/internal/session" + "bookmarkmanager/backend/internal/store" + "bookmarkmanager/backend/internal/web" ) const testPassword = "hunter2" @@ -193,7 +193,7 @@ func TestBookmarksAPIStillBearerOnly(t *testing.T) { func TestStaticAssetsServed(t *testing.T) { srv, _ := newWebTestServer(t, webConfig()) - for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js", "/static/logo.svg"} { + for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js", "/static/logo.svg", "/static/login-art.png"} { rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) if rr.Code != http.StatusOK { @@ -840,3 +840,127 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) { t.Fatalf("archiving did not clear the Updated badge out of band: %q", body) } } + +// seedLibraries puts one manga and one novel row in the store. +func seedLibraries(t *testing.T, st *store.Store) { + t.Helper() + seed(t, st, store.Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", Kind: store.KindManga, UpdatedAt: 2_000_000, + }) + seed(t, st, store.Bookmark{ + Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld", + SeriesID: "a-will-eternal", Title: "A Will Eternal", + Kind: store.KindNovel, UpdatedAt: 1_000_000, + }) +} + +func TestLibrariesAreDisjoint(t *testing.T) { + cfg := webConfig() + srv, st := newWebTestServer(t, cfg) + seedLibraries(t, st) + + cases := []struct { + name, path, want, absent string + }{ + {"manga is the default", "/ui/list?tab=all", "Solo Leveling", "A Will Eternal"}, + {"novel is opt-in", "/ui/list?lib=novel&tab=all", "A Will Eternal", "Solo Leveling"}, + {"unknown lib falls back to manga", "/ui/list?lib=comics&tab=all", "Solo Leveling", "A Will Eternal"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, tc.path, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, tc.want) { + t.Fatalf("%s missing from %s", tc.want, tc.path) + } + if strings.Contains(body, tc.absent) { + t.Fatalf("%s leaked into %s", tc.absent, tc.path) + } + }) + } +} + +// A row written before the kind column existed has none. It is manga. +func TestKindlessRowShowsInMangaLibrary(t *testing.T) { + cfg := webConfig() + srv, st := newWebTestServer(t, cfg) + seed(t, st, store.Bookmark{ + Key: "asura:legacy", Site: "asura", SeriesID: "legacy", + Title: "Legacy Series", UpdatedAt: 1_000_000, + }) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=all", nil)) + if !strings.Contains(rr.Body.String(), "Legacy Series") { + t.Fatal("a row with no kind must appear in the manga library") + } +} + +func TestNovelPageOmitsUpdatedTab(t *testing.T) { + cfg := webConfig() + srv, st := newWebTestServer(t, cfg) + seedLibraries(t, st) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?lib=novel&tab=all", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + if strings.Contains(body, "tab=new") { + t.Fatal("novel page must not offer the Updated tab") + } + // html/template escapes & to & inside an attribute value, so that — not + // the raw URL — is what lands in the body. htmx and the browser both decode + // it on read, so only the assertion has to know. + for _, want := range []string{ + "/?lib=novel&tab=fav", + "/?lib=novel&tab=archived", + "/?lib=novel&tab=finished", + } { + if !strings.Contains(body, want) { + t.Fatalf("novel page missing tab link %s", want) + } + } + if !strings.Contains(body, `class="libswitch"`) { + t.Fatal("novel page missing the library switch") + } +} + +func TestMangaPageKeepsUpdatedTab(t *testing.T) { + cfg := webConfig() + srv, st := newWebTestServer(t, cfg) + seedLibraries(t, st) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?tab=all", nil)) + body := rr.Body.String() + if !strings.Contains(body, "/?tab=new") { + t.Fatal("manga page must keep the Updated tab") + } + if strings.Contains(body, "lib=novel&tab=new") { + t.Fatal("the Updated tab must never be emitted for the novel library") + } +} + +// tab=new is not offered for novels, so a hand-typed one must land on All +// rather than an empty page. +func TestNovelNewTabFallsBackToAll(t *testing.T) { + cfg := webConfig() + srv, st := newWebTestServer(t, cfg) + seedLibraries(t, st) + + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?lib=novel&tab=new", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "A Will Eternal") { + t.Fatal("novel tab=new should render the novel All list") + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e58e286..4851023 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,11 +1,11 @@ # Production override: join an existing Traefik network and let Traefik route -# manga-api. -> this service with TLS. No host port published. +# bookmark-api. -> this service with TLS. No host port published. # # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # # Set in .env: -# MANGA_API_HOST=manga-api.example.com # your subdomain (required) -# MANGA_WEB_HOST=manga.example.com # browser UI subdomain, same container (required) +# BOOKMARK_API_HOST=bookmark-api.example.com # your subdomain (required) +# BOOKMARK_WEB_HOST=bookmark.example.com # browser UI subdomain, same container (required) # PROXY_NETWORK=proxy # Traefik's network name, if not "proxy" # TRAEFIK_ENTRYPOINT=websecure # your HTTPS entrypoint name # TRAEFIK_CERTRESOLVER=le # your ACME/cert resolver name @@ -14,7 +14,7 @@ # docker network create proxy # if it doesn't yet services: - manga-api: + bookmark-api: # Traffic arrives over the Traefik network, not a published port. ports: !reset [] environment: @@ -33,19 +33,19 @@ services: labels: - "traefik.enable=true" - "traefik.docker.network=${PROXY_NETWORK:-proxy}" - - "traefik.http.routers.mangabm.rule=Host(`${MANGA_API_HOST:?set MANGA_API_HOST in .env}`)" - - "traefik.http.routers.mangabm.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" - - "traefik.http.routers.mangabm.tls=true" - - "traefik.http.routers.mangabm.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" - - "traefik.http.services.mangabm.loadbalancer.server.port=8080" + - "traefik.http.routers.bmapi.rule=Host(`${BOOKMARK_API_HOST:?set BOOKMARK_API_HOST in .env}`)" + - "traefik.http.routers.bmapi.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.bmapi.tls=true" + - "traefik.http.routers.bmapi.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" + - "traefik.http.services.bmapi.loadbalancer.server.port=8080" # Second hostname for the browser UI, same container. Traefik needs the # service named explicitly once more than one router targets it. - - "traefik.http.routers.mangabm.service=mangabm" - - "traefik.http.routers.mangaweb.rule=Host(`${MANGA_WEB_HOST:?set MANGA_WEB_HOST in .env}`)" - - "traefik.http.routers.mangaweb.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" - - "traefik.http.routers.mangaweb.tls=true" - - "traefik.http.routers.mangaweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" - - "traefik.http.routers.mangaweb.service=mangabm" + - "traefik.http.routers.bmapi.service=bmapi" + - "traefik.http.routers.bmweb.rule=Host(`${BOOKMARK_WEB_HOST:?set BOOKMARK_WEB_HOST in .env}`)" + - "traefik.http.routers.bmweb.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.bmweb.tls=true" + - "traefik.http.routers.bmweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" + - "traefik.http.routers.bmweb.service=bmapi" # headless-shell is untouched here: it keeps its `browser` network membership # from the base file and must never join `proxy` — that network is shared diff --git a/docker-compose.yml b/docker-compose.yml index a819f16..bf3f0fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,27 +1,30 @@ # Base stack — works standalone for local smoke testing (`docker compose up`). # The service binds 127.0.0.1:8080; a host reverse proxy (nginx/Caddy/Traefik) -# terminates TLS for manga-api. and forwards to it. +# terminates TLS for bookmark-api. and forwards to it. # # If your proxy runs in Docker on its own network, use the prod override which # attaches to that network instead of publishing a port: # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d services: - manga-api: + bookmark-api: build: ./backend - image: mangabm-backend:latest - container_name: manga-api + image: bookmarkmanager-backend:latest + container_name: bookmark-api restart: unless-stopped environment: # API_TOKEN is required — compose refuses to start without it. API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env} - ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to,https://novelfull.com,https://lightnovelworld.net} DB_PATH: /data/bookmarks.db PORT: "8080" # Gates the browser UI. Unset means the web routes are not served at all. WEB_PASSWORD: ${WEB_PASSWORD:-} # Path inside the container; matches the bindmount above. USERSCRIPT_PATH: ${USERSCRIPT_PATH:-/userscript/manga-bookmark.user.js} + # Second script from the same bindmount; the novel library is a separate + # Violentmonkey install. + NOVEL_USERSCRIPT_PATH: ${NOVEL_USERSCRIPT_PATH:-/userscript/novel-bookmark.user.js} # Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the kill # switch; it only takes effect because these are listed here. LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1} @@ -63,7 +66,7 @@ services: # container's lifetime. init: true # Deliberately no `ports:` — an exposed CDP endpoint is remote code - # execution. Only manga-api, via the `browser` network below, may reach it. + # execution. Only bookmark-api, via the `browser` network below, may reach it. # Don't pass --remote-debugging-address/--remote-debugging-port here: the # image's own entrypoint (/headless-shell/run.sh) already starts Chrome on # 127.0.0.1:9223 and fronts it with a socat proxy listening on 0.0.0.0:9222. @@ -86,7 +89,7 @@ volumes: networks: # Not `internal: true`: headless Chrome still needs outbound access to reach - # kagane.to. Isolation here comes from membership (only manga-api and + # kagane.to. Isolation here comes from membership (only bookmark-api and # headless-shell join it), not from cutting egress. browser: ipam: diff --git a/docs/design-system.md b/docs/design-system.md index 9aed761..9f4aeb7 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,6 +1,6 @@ -# Cinder — mangaBookmark design system +# Cinder — BookmarkManager design system -Source of truth: the Claude Design project **mangaBookmark Web UI** +Source of truth: the Claude Design project **BookmarkManager Web UI** (`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings `archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`, `style.css`, `filter.js`). This file records the rules that got implemented so @@ -10,7 +10,7 @@ Implemented in: | Surface | Files | | --- | --- | -| Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,chrome,icons}.html`, `backend/static/filter.js` | +| Web UI (login, list, card, empty, errors) | `backend/internal/web/static/style.css`, `backend/internal/web/templates/{app,card,list,login,chrome,icons}.html`, `backend/internal/web/static/filter.js` | | Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE | ## 1. The one idea @@ -41,7 +41,7 @@ Corollaries: ## 2. Tokens -Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's +Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the userscript's `:host`. **Never hardcode a hex outside those two blocks.** | Token | Dark | Light | Use | @@ -77,6 +77,8 @@ Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's | `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on | | `--asura` | `#7d93a5` | `#4f6b80` | site tag | | `--demonic` | `#a98a78` | `#8a6a55` | site tag | +| `--comix` | `#8a9a7d` | `#5f7250` | site tag | +| `--kagane` | `#9a8aa5` | `#6f5f7d` | site tag | | `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot | `--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately: @@ -95,7 +97,7 @@ dark, the hues are re-tuned. | Sans | `DM Sans` → system UI | system UI | The web UI **self-hosts** all three: five latin-subset woff2 files in -`backend/static/fonts/` (~120 KB total), declared by the `@font-face` block at +`backend/internal/web/static/fonts/` (~120 KB total), declared by the `@font-face` block at the top of `style.css` and embedded in the binary by the existing `//go:embed static`. There is no request to Google — this UI needs to survive on a LAN with no internet route. `staticHandler()` in `web.go` registers the @@ -116,7 +118,7 @@ root is at the mercy of the host site's CSP. Recurring specs (copy these rather than inventing sizes): - Brand: `400 26px/1 display` (`30px` ≥720px), inline SVG mark (§4) + `` in - ember italic — `mangaBookmark`. + ember italic — `BookmarkManager`. - Row title: `400 21px/1.2 display` (`22px` ≥720px). - Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in `--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on @@ -141,18 +143,19 @@ Recurring specs (copy these rather than inventing sizes): **Brand mark**: an inline `` (`viewBox="0 0 200 172"`), defined once in `chrome.html`'s `mark` template and reused by `app.html` and `login.html` so it takes the page's `--ink`/`currentColor`/`--ember` rather -than shipping as a static asset. The blade at its centre strokes -`var(--logo-blade, var(--ember))` — override that custom property, don't -duplicate the SVG, if a surface ever needs a different blade colour. Drawn at -a 5px stroke on a 200-unit grid; at brand size that thins out, so `.brand .mark -g` nudges `stroke-width` up to `6.5` rather than scaling the artwork down. +than shipping as a static asset. The blade at its centre strokes `var(--ember)`, +so a surface that needs a different blade colour re-points that token rather +than duplicating the SVG. Drawn at a 5px stroke on a 200-unit grid; at brand +size that thins out, so `.brand .mark g` nudges `stroke-width` up to `6.5` +rather than scaling the artwork down. **Action key** (`.keyrow`): one permanent line under the tabs naming what every icon in `.actions` does — Read / Fav / Chapter / Archive / Done / -Delete — so the icon strip on a card is never a guess. On a phone each pair -stacks icon-over-word (`flex-direction: column`) so the word gets the full -cell width and can stay in long form; ≥720px it lays out icon-beside-word and -switches the `.short`/`.full` label pair. `.pair.brass` and `.pair.trash` +Delete — so the icon strip on a card is never a guess. The key follows the tab, +not the row: Archive becomes Restore under Archived and Finished, and Finished +drops Done. On a phone each pair stacks icon-over-word +(`flex-direction: column`) so the word gets the full cell width; ≥720px it lays +out icon-beside-word at the same wording. `.pair.brass` and `.pair.trash` carry their icon's resting accent so the key itself teaches the colour vocabulary in §1/§2. diff --git a/userscript/AGENTS.md b/userscript/AGENTS.md new file mode 100644 index 0000000..6c9e9c3 --- /dev/null +++ b/userscript/AGENTS.md @@ -0,0 +1,64 @@ +Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system. + +### Userscript structure (single IIFE, `manga-bookmark.user.js`) + +1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes. +2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `bmgr:manga:cache` for instant render + offline fallback. +3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. +4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so + failed mutation park in `localStorage` (`bmgr:manga:queue`) and replayed on + next navigation, reconnect, or `refresh()`. Entries are markers + (`{key, op, sendStatus, attempts}`), never payloads — body read from + cache at send time, so one entry per key give ordering and coalescing for + free. `sendStatus` is **sticky**: while archive pending, later writes to + that key keep carrying bucket, which stop successful + in-between write from silently un-archiving series. `refresh()` drains + before it fetches and overlays anything still pending, so list never + flaps. 400 drops entry, 401 abort pass and keep queue, and + transient failures retry to cap of 10. Latest-chapter writes deliberately + stay out of queue. See + `docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`. +5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS + (critical on mobile). Three tabs (All / Favourites / Archived) and row of + link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG + block next to `API_BASE`. FAB is `7 × 44` edge tab whose *hit* area + widened to `28 × 72` by invisible `#hit` child; `#fab` must keep + `touch-action: none` and must **not** regain `overflow: hidden`. Since + `touch-action` resolved at gesture start, strip can't be both + browser-scrolled and script-dragged, so `makeDraggable` splits by intent: swipe + from `#hit` scrolls via `window.scrollBy`, hold of `ARM_MS` arms + reposition drag, visible sliver drags with no hold. See + `docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`. +6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fire without reload. Demonic uses classic reloads (initial `document-idle` run suffice). + +### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust) + +- **asurascans.com**: series `/comics/` (slug carries trailing + site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every + redeploy**), chapter `/comics//chapter/`. `seriesId` must strip + hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in userscript, + `asuraBuildHash` in backend); URLs keep full slug — stale-hash + URLs 302 to current ones. Astro-rendered; chapter links present in raw + server HTML. +- **demonicscans.org**: series `/manga/` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title//chapter//` (older `chaptered.php?manga=&chapter=` form still exists as redirect, what series-page chapter-list anchors link through). + Encodings (incl. triple-encoded punctuation like `%25252D`) identical + on /manga/ and /title/ pages, so decode-once seriesIds match — verified + 2026-07-28. +- **novelfull.com** (novel script): series `/.html`, chapter + `//chapter-[-].html`. No `og:*` tags at all — title from + `h3.title` (series) or `a.truyen-title` (chapter), cover from + `meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint + clears, so the backend polls it through the headless browser. +- **lightnovelworld.net** (novel script): series `/novel//`, chapter + `/-chapter-/` — flat, at the site root. `h1.entry-title` is the clean + title on a series page and ` Chapter ` on a chapter page. Chapter + pages carry no `og:image`. Its series page lists every chapter with an + absolute href, so the backend polls it with the plain TLS client. + +### Second script: `novel-bookmark.user.js` + +A copy of the manga script with two adapters, `LIBRARY = "novel"` and +`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous +installation to carry keys over from). Installed alongside the manga script; +both write to the same backend with the same `LIBRARY` column discriminating +them. diff --git a/userscript/CLAUDE.md b/userscript/CLAUDE.md index 9f1c225..608d8a0 100644 --- a/userscript/CLAUDE.md +++ b/userscript/CLAUDE.md @@ -3,10 +3,10 @@ Guidance for Claude Code working under `userscript/`. See root `CLAUDE.md` for t ### Userscript structure (single IIFE, `manga-bookmark.user.js`) 1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes. -2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback. +2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `bmgr:manga:cache` for instant render + offline fallback. 3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. 4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so - failed mutation park in `localStorage` (`mangabm:queue`) and replayed on + failed mutation park in `localStorage` (`bmgr:manga:queue`) and replayed on next navigation, reconnect, or `refresh()`. Entries are markers (`{key, op, sendStatus, attempts}`), never payloads — body read from cache at send time, so one entry per key give ordering and coalescing for @@ -44,3 +44,21 @@ Guidance for Claude Code working under `userscript/`. See root `CLAUDE.md` for t Encodings (incl. triple-encoded punctuation like `%25252D`) identical on /manga/ and /title/ pages, so decode-once seriesIds match — verified 2026-07-28. +- **novelfull.com** (novel script): series `/.html`, chapter + `//chapter-[-].html`. No `og:*` tags at all — title from + `h3.title` (series) or `a.truyen-title` (chapter), cover from + `meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint + clears, so the backend polls it through the headless browser. +- **lightnovelworld.net** (novel script): series `/novel//`, chapter + `/-chapter-/` — flat, at the site root. `h1.entry-title` is the clean + title on a series page and ` Chapter <n>` on a chapter page. Chapter + pages carry no `og:image`. Its series page lists every chapter with an + absolute href, so the backend polls it with the plain TLS client. + +### Second script: `novel-bookmark.user.js` + +A copy of the manga script with two adapters, `LIBRARY = "novel"` and +`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous +installation to carry keys over from). Installed alongside the manga script; +both write to the same backend with the same `LIBRARY` column discriminating +them. diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 02b31d5..bec8d4a 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -4,8 +4,8 @@ // @version 1.6.0 // @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). // @author you -// @downloadURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js -// @updateURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js +// @downloadURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js +// @updateURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js // @match https://asuracomic.net/* // @match https://asurascans.com/* // @match https://demonicscans.org/* @@ -21,19 +21,38 @@ // ============================================================ // CONFIG — fill these in before installing. // ============================================================ - const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash + const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN - const WEB_BASE = "https://manga.violetcrown.my.id"; // the browser UI, for the panel's nav chips + const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips + + // This script owns the manga library; the novel script is a separate install + // with its own prefix, so the two never share a cache, a queue or a panel. + const STORE_PREFIX = "bmgr:manga:"; + + // Which library this script's rows belong to. The novel script is a separate + // install that declares "novel"; the backend keeps whichever it is told. + const LIBRARY = "manga"; // Safe in Bromite's isolated world: the page's own JS cannot read these. - const CACHE_KEY = "mangabm:cache"; + const CACHE_KEY = STORE_PREFIX + "cache"; // Per-device record of when each series was last checked for new chapters. // Deliberately not synced: each device does its own checking. - const LASTCHECKED_KEY = "mangabm:lastchecked"; + const LASTCHECKED_KEY = STORE_PREFIX + "lastchecked"; const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000; const LATEST_CHECK_BATCH = 1; // series fetched per navigation + // One-time carry-over from the pre-rebrand key names. The cache would rebuild + // itself from the server, but the retry queue would not: dropping it loses + // writes made while offline. + for (const name of ["cache", "lastchecked", "queue", "fabpos"]) { + const old = localStorage.getItem("mangabm:" + name); + if (old !== null && localStorage.getItem(STORE_PREFIX + name) === null) { + localStorage.setItem(STORE_PREFIX + name, old); + } + localStorage.removeItem("mangabm:" + name); + } + // ============================================================ // Site adapters // @@ -503,7 +522,9 @@ } function setList(list) { - state.list = Array.isArray(list) ? list : []; + // GET /bookmarks answers with every library; this panel owns one of them, + // and the cache must not hold rows it can never show. + state.list = (Array.isArray(list) ? list : []).filter((b) => kindOf(b) === LIBRARY); reindex(); saveCache(state.list); } @@ -528,19 +549,25 @@ return b.status || "reading"; } + // A list cached by an older version has no kind field, and a row the server + // defaulted has "manga" — both mean the same thing here. + function kindOf(b) { + return b.kind || "manga"; + } + // ============================================================ // Retry queue // // A failed write is not rolled back and not lost: the key is parked here and // replayed when the backend is next reachable. Entries carry no payload — the // body is read from state.byKey at send time, because state.list *is* the - // desired state and is already persisted in mangabm:cache. One entry per key, + // desired state and is already persisted under CACHE_KEY. One entry per key, // so two writes to the same series cannot replay out of order, an // archive-then-unarchive collapses to whatever the cache now says, and a // queued DELETE replaces a queued PUT rather than racing it. // ============================================================ - const QUEUE_KEY = "mangabm:queue"; + const QUEUE_KEY = STORE_PREFIX + "queue"; const QUEUE_MAX = 200; // ~12 KB; realistically bounded by the bookmark count const QUEUE_MAX_ATTEMPTS = 10; @@ -812,6 +839,7 @@ const bm = { key: key, site: p.site, + kind: LIBRARY, series_id: p.seriesId, title: p.title || (existing && existing.title) || p.seriesId, series_url: p.seriesUrl || (existing && existing.series_url) || "", @@ -834,6 +862,7 @@ const bm = Object.assign({}, existing, { key: key, site: p.site, + kind: LIBRARY, series_id: p.seriesId, title: existing.title || p.title || p.seriesId, series_url: existing.series_url || p.seriesUrl || "", @@ -1075,7 +1104,7 @@ // FAB placement + dragging (snaps to nearest left/right edge) // ============================================================ - const FAB_KEY = "mangabm:fabpos"; + const FAB_KEY = STORE_PREFIX + "fabpos"; const FAB_MARGIN = 12; // vertical breathing room at the top and bottom const FAB_EDGE = 0; // horizontal: an edge tab sits flush against the side const ARM_MS = 400; // hold this long on the invisible strip to arm a drag @@ -1580,7 +1609,7 @@ </g> </g> </svg> - <span>manga<em>Bookmark</em></span> + <span>Bookmark<em>Manager</em></span> </span> <button id="closeBtn" aria-label="Close">close</button> </header> @@ -1784,7 +1813,7 @@ // Exposes pure logic only — see userscript/test/logic.test.js. // ============================================================ if (typeof window === "undefined" && typeof module === "object" && module.exports) { - module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf }; + module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf, kindOf }; } // ============================================================ diff --git a/userscript/novel-bookmark.user.js b/userscript/novel-bookmark.user.js new file mode 100644 index 0000000..df82b71 --- /dev/null +++ b/userscript/novel-bookmark.user.js @@ -0,0 +1,1590 @@ +// ==UserScript== +// @name Novel Bookmark Sync +// @namespace bmgr +// @version 1.0.0 +// @description Track read progress on NovelFull & LightNovelWorld and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). +// @author you +// @downloadURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/novel-bookmark.user.js +// @updateURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/novel-bookmark.user.js +// @match https://novelfull.com/* +// @match https://lightnovelworld.net/* +// @run-at document-idle +// @noframes +// ==/UserScript== + +(function () { + "use strict"; + + // ============================================================ + // CONFIG — fill these in before installing. + // ============================================================ + const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash + const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN + const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips + + // This script owns the novel library; the manga script is a separate install + // with its own prefix, so the two never share a cache, a queue or a panel. + const STORE_PREFIX = "bmgr:novel:"; + + // Which library this script's rows belong to. The manga script is a separate + // install that declares "manga"; the backend keeps whichever it is told. + const LIBRARY = "novel"; + + // ============================================================ + // Site adapters + // + // Page type + IDs come from URL regex (most stable); title/cover come from + // og: meta tags (with the novelfull name= meta as the exception). + // ============================================================ + + + function meta(prop) { + const el = document.querySelector('meta[property="' + prop + '"]'); + return el ? el.getAttribute("content") : null; + } + + // Chapter lists are read from two places: the page we are standing on, and + // series pages fetched in the background. Both are reduced to {href, text} + // pairs so each adapter needs only one rule for picking the latest chapter. + + function anchorsFromDocument(doc) { + return Array.from(doc.querySelectorAll("a[href]")).map((a) => ({ + href: a.getAttribute("href") || "", + text: a.textContent || "", + })); + } + + // A fetched page is raw text; DOMParser would run against markup we do not + // control, so anchors are pulled out directly instead. + function anchorsFromHTML(html) { + const re = /<a\b[^>]*\shref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi; + const out = []; + let m; + while ((m = re.exec(html))) { + out.push({ href: m[1], text: m[2].replace(/<[^>]*>/g, " ") }); + } + return out; + } + + // novelfull ships no og: tags at all — its cover lives on a name= meta. + function metaName(name) { + const el = document.querySelector('meta[name="' + name + '"]'); + return el ? el.getAttribute("content") : null; + } + + function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + // Neither site lists chapters in a dependable order — novelfull puts a + // newest-first widget above an oldest-first page, lightnovelworld lists all + // of them ascending — so the newest is the maximum, never the first or last. + function maxChapter(anchors, re) { + let best = null; + for (const a of anchors) { + const m = (a.href || "").match(re); + if (!m) continue; + const num = parseFloat(m[1]); + if (isNaN(num)) continue; + if (!best || num > best.num) best = { num: num, label: "Chapter " + m[1] }; + } + return best; + } + + const novelfull = { + site: "novelfull", + matches: (loc) => /(^|\.)novelfull\.com$/.test(loc.hostname), + detect(loc) { + const path = loc.pathname; + // /<slug>/chapter-<n>[-<title-slug>].html + let m = path.match(/^\/([^/]+)\/chapter-([0-9]+(?:\.[0-9]+)?)/); + if (m) { + const num = parseFloat(m[2]); + const back = document.querySelector("a.truyen-title"); + return { + type: "chapter", + site: this.site, + seriesId: m[1], + // h3.title on a chapter page is the *chapter's* title; the breadcrumb + // link back to the series page carries the series name. + title: back ? (back.textContent || "").trim() : "", + cover: metaName("image") || "", + seriesUrl: loc.origin + "/" + m[1] + ".html", + chapterLabel: "Chapter " + m[2], + chapterNum: isNaN(num) ? null : num, + chapterUrl: loc.href, + }; + } + // /<slug>.html + m = path.match(/^\/([^/]+)\.html$/); + if (m) { + const h3 = document.querySelector("h3.title"); + return { + type: "series", + site: this.site, + seriesId: m[1], + title: h3 ? (h3.textContent || "").trim() : "", + cover: metaName("image") || "", + seriesUrl: loc.origin + "/" + m[1] + ".html", + chapterLabel: null, + chapterNum: null, + chapterUrl: null, + }; + } + return { type: "other" }; + }, + latestChapterFromAnchors(anchors, seriesId) { + return maxChapter(anchors, new RegExp("/" + escapeRe(seriesId) + "/chapter-([0-9.]+)")); + }, + }; + + const lightnovelworld = { + site: "lightnovelworld", + matches: (loc) => /(^|\.)lightnovelworld\.net$/.test(loc.hostname), + detect(loc) { + const path = loc.pathname; + // /<slug>-chapter-<n>/ — flat, at the site root, not under /novel/. The + // greedy group takes the LAST "-chapter-", so a slug containing those + // words still resolves to the right series. + let m = path.match(/^\/(.+)-chapter-([0-9]+(?:\.[0-9]+)?)\/?$/); + if (m) { + const num = parseFloat(m[2]); + const h1 = document.querySelector("h1.entry-title"); + const heading = h1 ? h1.textContent || "" : ""; + return { + type: "chapter", + site: this.site, + seriesId: m[1], + // The heading is "<Series> Chapter <n>"; drop the suffix. + title: heading.replace(/\s*Chapter\s+[0-9.]+\s*$/i, "").trim(), + // Chapter pages carry no og:image. Empty is safe: every write merges + // against the cached row, which keeps the cover the series page gave. + cover: "", + seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/", + chapterLabel: "Chapter " + m[2], + chapterNum: isNaN(num) ? null : num, + chapterUrl: loc.href, + }; + } + // /novel/<slug>/ + m = path.match(/^\/novel\/([^/]+)\/?$/); + if (m) { + const h1 = document.querySelector("h1.entry-title"); + return { + type: "series", + site: this.site, + seriesId: m[1], + title: h1 ? (h1.textContent || "").trim() : "", + cover: meta("og:image") || "", + seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/", + chapterLabel: null, + chapterNum: null, + chapterUrl: null, + }; + } + return { type: "other" }; + }, + latestChapterFromAnchors(anchors, seriesId) { + return maxChapter( + anchors, + new RegExp("lightnovelworld\\.net/" + escapeRe(seriesId) + "-chapter-([0-9.]+)/") + ); + }, + }; + + const ADAPTERS = [novelfull, lightnovelworld]; + + function detect() { + const loc = window.location; + for (const a of ADAPTERS) { + if (a.matches(loc)) return a.detect(loc); + } + return { type: "other" }; + } + + function keyOf(page) { + return page.site + ":" + page.seriesId; + } + + function adapterFor(site) { + return ADAPTERS.find((a) => a.site === site) || null; + } + + // Highest chapter the site lists, or null when the markup yields nothing. + // seriesId is only consulted by adapters whose pages carry other series' + // chapter links; the rest ignore it. + function computeLatestChapter(site, anchors, seriesId) { + const a = adapterFor(site); + return a ? a.latestChapterFromAnchors(anchors, seriesId) : null; + } + + function currentSite() { + for (const a of ADAPTERS) if (a.matches(window.location)) return a.site; + return null; + } + + // ============================================================ + // API client + local cache + // ============================================================ + + function loadCache() { + try { + const raw = localStorage.getItem(CACHE_KEY); + return raw ? JSON.parse(raw) : []; + } catch (e) { + return []; + } + } + + function saveCache(list) { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(list)); + } catch (e) { + /* quota / private mode — ignore */ + } + } + + function loadLastChecked() { + try { + const raw = localStorage.getItem(LASTCHECKED_KEY); + return raw ? JSON.parse(raw) : {}; + } catch (e) { + return {}; + } + } + + function saveLastChecked(map) { + try { + localStorage.setItem(LASTCHECKED_KEY, JSON.stringify(map)); + } catch (e) { + /* quota / private mode — ignore */ + } + } + + function authHeaders(extra) { + return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {}); + } + + // The retry queue treats a 400 differently from a dropped connection, so the + // status has to survive the throw. A network failure leaves it undefined. + function httpError(what, status) { + const e = new Error(what + " " + status); + e.status = status; + return e; + } + + async function apiGet() { + const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() }); + if (!res.ok) throw new Error("GET /bookmarks " + res.status); + return res.json(); + } + + // Only an explicit archive/restore has an opinion about the bucket. Every + // other write omits `status`, so the server keeps the stored one — otherwise + // a cached value would resend "finished" (which the API rejects with 400) or + // silently un-archive a series archived on another device. + async function apiPut(key, obj, { sendStatus = false } = {}) { + const body = Object.assign({}, obj); + if (!sendStatus) delete body.status; + const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { + method: "PUT", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(body), + }); + if (!res.ok) throw httpError("PUT /bookmarks", res.status); + return res.json(); + } + + async function apiDelete(key) { + const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { + method: "DELETE", + headers: authHeaders(), + }); + // 404 means the row is already gone, which is what the caller wanted. + if (!res.ok && res.status !== 404) throw httpError("DELETE /bookmarks", res.status); + } + + // ============================================================ + // State + // ============================================================ + + const state = { + list: loadCache(), // last-known bookmarks (array) + byKey: {}, + page: { type: "other" }, + }; + reindex(); + + function reindex() { + state.byKey = {}; + for (const b of state.list) state.byKey[b.key] = b; + state.list.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0)); + } + + function setList(list) { + // GET /bookmarks answers with every library; this panel owns one of them, + // and the cache must not hold rows it can never show. + state.list = (Array.isArray(list) ? list : []).filter((b) => kindOf(b) === LIBRARY); + reindex(); + saveCache(state.list); + } + + function upsertLocal(bm) { + const i = state.list.findIndex((b) => b.key === bm.key); + if (i >= 0) state.list[i] = bm; + else state.list.push(bm); + reindex(); + saveCache(state.list); + } + + function removeLocal(key) { + state.list = state.list.filter((b) => b.key !== key); + reindex(); + saveCache(state.list); + } + + // A list cached by an older version has no status field, and a row the + // server defaulted has "reading" — both mean the same thing here. + function statusOf(b) { + return b.status || "reading"; + } + + // A list cached by an older version has no kind field, and a row the server + // defaulted has "manga" — both mean the same thing here. + function kindOf(b) { + return b.kind || "manga"; + } + + // ============================================================ + // Retry queue + // + // A failed write is not rolled back and not lost: the key is parked here and + // replayed when the backend is next reachable. Entries carry no payload — the + // body is read from state.byKey at send time, because state.list *is* the + // desired state and is already persisted under CACHE_KEY. One entry per key, + // so two writes to the same series cannot replay out of order, an + // archive-then-unarchive collapses to whatever the cache now says, and a + // queued DELETE replaces a queued PUT rather than racing it. + // ============================================================ + + const QUEUE_KEY = STORE_PREFIX + "queue"; + const QUEUE_MAX = 200; // ~12 KB; realistically bounded by the bookmark count + const QUEUE_MAX_ATTEMPTS = 10; + + let authFailed = false; // a 401 was seen; retrying cannot help until the token changes + + function loadQueue() { + try { + const raw = localStorage.getItem(QUEUE_KEY); + return raw ? JSON.parse(raw) : []; + } catch (e) { + return []; + } + } + + function saveQueue(q) { + try { + localStorage.setItem(QUEUE_KEY, JSON.stringify(q)); + } catch (e) { + /* quota / private mode — ignore */ + } + } + + const queue = loadQueue(); + + + + function queueCount() { + return queue.length; + } + + function queueGet(key) { + return queue.find((e) => e.key === key) || null; + } + + function titleFor(key) { + const b = state.byKey[key]; + return (b && (b.title || b.series_id)) || key; + } + + // Oldest goes first when the cap is hit: the newest entry is the action the + // user just took, and dropping that is the bug this queue exists to fix. + function queuePush(entry) { + if (queue.length >= QUEUE_MAX) { + const evicted = queue.shift(); + toast("Sync queue full — dropped " + titleFor(evicted.key), true); + } + queue.push(entry); + return entry; + } + + function queueDrop(key) { + const i = queue.findIndex((e) => e.key === key); + if (i >= 0) { + queue.splice(i, 1); + saveQueue(queue); + } + } + + // Parks a write without counting it as a failure. Used when the key is already + // on the wire: the write is owed, but nothing went wrong, so it must not spend + // one of the ten attempts. + function queueDefer(key, op, sendStatus) { + const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 }); + e.op = op; // a delete replaces a put, and a put replaces a delete + e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped + saveQueue(queue); + return e; + } + + // Records a failed write and classifies why it failed. A 400 is a payload the + // server will never accept, so it is dropped now instead of being retried ten + // times; a 401 is the wrong token, so the entry is kept untouched and the + // drain gives up until the token changes. + function queueEnqueue(key, op, sendStatus, err) { + const status = err && err.status; + if (status === 400) { + queueDrop(key); + toast("Couldn't sync " + titleFor(key) + " — change lost", true); + return; + } + const e = queueDefer(key, op, sendStatus); + if (status === 401) { + authFailed = true; + } else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) { + queueDrop(key); + toast("Gave up syncing " + titleFor(key), true); + return; + } + saveQueue(queue); + } + + // Keys with a request on the wire right now, mapped to "another write arrived + // while this one was flying". A second write to the same key is never sent + // concurrently — the two responses would race to own the row — it is parked in + // the queue instead and a later drain replays it. The flight already in the + // air must then leave that entry alone and not adopt its own now-stale + // response, or it would undo the write the user just made. + const inFlight = new Map(); + + let draining = null; // the pass in progress, so a second caller awaits it + + // Replays everything owed. Cheap in the normal case — it returns on the first + // line when the queue is empty, which is why it can hang off navigation. A + // 401 stops the whole pass: the token is wrong, so the next entry would fail + // the same way, and the queue is left intact so fixing the token fixes it. + // + // Returns the in-flight pass when one is already running, so refresh()'s + // `await drain()` really does wait for what we owe instead of racing a drain + // that onNavigate or the online listener started unawaited. + function drain() { + if (queue.length === 0) return Promise.resolve(); + if (draining) return draining; + const pass = (async () => { + authFailed = false; + try { + for (const e of queue.slice()) { + if (e.op === "delete") await pushDelete(e.key); + else await pushBookmark(e.key, e.sendStatus); + if (authFailed) { + toast("Sync auth failed — check the token", true); + break; + } + } + } finally { + render(); + } + })(); + // Swallowed, not surfaced: drain is called unawaited from onNavigate and the + // online listener, and an uncaught rejection on a page we do not control is + // a console error nobody can act on. Every real sync failure is already + // toasted and queued by pushBookmark/pushDelete. + draining = pass + .catch(() => {}) + .finally(() => { + draining = null; + }); + return draining; + } + + // Keys the server has not heard about yet must survive a fetched list, or the + // card the user just changed silently flaps back — the exact bug this queue + // exists to fix. Reads state.byKey, so it must run *before* setList replaces + // it. + function overlayPending(list) { + if (queue.length === 0) return list; + const out = list.filter((b) => { + const e = queueGet(b.key); + return !(e && e.op === "delete"); + }); + for (const e of queue) { + if (e.op !== "put") continue; + const local = state.byKey[e.key]; + if (!local) continue; + const i = out.findIndex((b) => b.key === e.key); + if (i >= 0) out[i] = local; + else out.push(local); + } + return out; + } + + // ============================================================ + // Mutations (optimistic: update UI/cache first, then sync) + // ============================================================ + + // Every write goes through here, queued or not, so there is one place that + // talks to the API and one place that decides `sendStatus`. That flag is + // sticky: while an archive is pending for a key, a later progress write to the + // same key still carries the bucket — otherwise the server would answer with + // the old status and the adopted row would silently un-archive the series. + async function pushBookmark(key, sendStatus) { + const pending = queueGet(key); + const withStatus = sendStatus || (pending ? pending.sendStatus : false); + const bm = state.byKey[key]; + if (!bm) { + queueDrop(key); // removed locally in the meantime — nothing left to send + return true; + } + if (inFlight.has(key)) { + queueDefer(key, "put", withStatus); // see inFlight — parked, not sent + inFlight.set(key, true); // supersedes the flight already in the air + return false; + } + inFlight.set(key, false); + let ok = false; + try { + // ponytail: last-write-wins, so a replay can overwrite a newer server + // value (a poller-written latest_chapter, or progress from another + // device). Single user, self-healing on the next poll — revisit only if + // this ever runs multi-user. + const saved = await apiPut(key, bm, { sendStatus: withStatus }); + if (!inFlight.get(key)) { + upsertLocal(saved); + queueDrop(key); + } + ok = true; + } catch (e) { + // Same guard as the success path: a newer write is parked for this key, so + // classifying *this* failure would clobber its op or, on a 400, drop it + // outright. Its own drain reports its own outcome. The one thing that must + // still carry across is sendStatus — this flight may have been the archive + // replay, and losing its flag here would narrow the parked write into a + // silent un-archive. + const parked = inFlight.get(key) ? queueGet(key) : null; + if (parked) queueDefer(key, parked.op, withStatus); + else queueEnqueue(key, "put", withStatus, e); + } finally { + inFlight.delete(key); + } + // Outside the try: a throw in render() is a UI bug, not a write failure, and + // must not re-queue a write that already landed. + if (ok) render(); + return ok; + } + + async function pushDelete(key) { + if (inFlight.has(key)) { + queueDefer(key, "delete", false); // see inFlight — parked, not sent + inFlight.set(key, true); // supersedes the flight already in the air + return false; + } + inFlight.set(key, false); + try { + await apiDelete(key); + if (!inFlight.get(key)) queueDrop(key); + return true; + } catch (e) { + if (!inFlight.get(key)) queueEnqueue(key, "delete", false, e); // see pushBookmark + return false; + } finally { + inFlight.delete(key); + } + } + + async function bookmarkCurrent() { + const p = state.page; + if (p.type !== "series" && p.type !== "chapter") return; + const key = keyOf(p); + const existing = state.byKey[key]; + const bm = { + key: key, + site: p.site, + kind: LIBRARY, + series_id: p.seriesId, + title: p.title || (existing && existing.title) || p.seriesId, + series_url: p.seriesUrl || (existing && existing.series_url) || "", + cover: p.cover || (existing && existing.cover) || "", + last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "", + last_chapter_num: + p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null, + last_chapter_url: p.chapterUrl || (existing && existing.last_chapter_url) || "", + updated_at: Date.now(), + }; + await syncUpsert(bm, "Bookmarked"); + } + + async function updateToCurrentChapter() { + cancelDwell(); // manual button + timer-fire both clear any pending dwell + const p = state.page; + if (p.type !== "chapter") return; + const key = keyOf(p); + const existing = state.byKey[key] || {}; + const bm = Object.assign({}, existing, { + key: key, + site: p.site, + kind: LIBRARY, + series_id: p.seriesId, + title: existing.title || p.title || p.seriesId, + series_url: existing.series_url || p.seriesUrl || "", + cover: existing.cover || p.cover || "", + last_chapter: p.chapterLabel || existing.last_chapter || "", + last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num, + last_chapter_url: p.chapterUrl || "", + updated_at: Date.now(), + }); + await syncUpsert(bm, "Progress updated"); + } + + async function syncUpsert(bm, okMsg) { + upsertLocal(bm); // optimistic + render(); + if (await pushBookmark(bm.key, false)) toast(okMsg); + else toast("Saved — will sync when online", true); + } + + async function toggleFavorite(key) { + const existing = state.byKey[key]; + if (!existing) return; + const bm = Object.assign({}, existing, { + favorite: !existing.favorite, + updated_at: Date.now(), // candidate only; the server keeps the stored one + }); + upsertLocal(bm); // optimistic + render(); + if (!(await pushBookmark(bm.key, false))) toast("Saved — will sync when online", true); + } + + // Archive parks a series: it leaves All and Favourites but the server keeps + // polling it for new chapters. "finished" is deliberately not reachable from + // here — the API rejects that value, it is a web-UI decision. + async function toggleArchive(key) { + const existing = state.byKey[key]; + if (!existing) return; + const next = statusOf(existing) === "archived" ? "reading" : "archived"; + upsertLocal(Object.assign({}, existing, { status: next })); + render(); + // The only write with an opinion about the bucket, so the only one that + // sends `status` at all. + if (await pushBookmark(key, true)) { + toast(next === "archived" ? "Archived" : "Back in your list"); + } else { + toast((next === "archived" ? "Archived" : "Restored") + " — will sync when online", true); + } + } + + // Records the newest chapter a site has published. Silent: this fires from + // page visits and background checks the user did not ask for, and it never + // reorders the list — updated_at is a candidate the server discards unless + // reading progress moved. + async function applyLatestChapterIfChanged(existing, latest) { + if (!existing || !latest) return; + if (existing.latest_chapter_num === latest.num) return; + const bm = Object.assign({}, existing, { + latest_chapter: latest.label, + latest_chapter_num: latest.num, + updated_at: Date.now(), + }); + upsertLocal(bm); + render(); + // A queued write owns this row; the drain sends latest_chapter + // with it, carrying the correct bucket. + if (queueGet(bm.key)) return; + try { + const saved = await apiPut(bm.key, bm); + upsertLocal(saved); + render(); + } catch (e) { + /* offline — the local cache still shows it, retried on a later visit */ + } + } + + // Standing on a bookmarked series page: its chapter list is already here. + function maybeCaptureLatestOnSeriesPage() { + const p = state.page; + if (p.type !== "series") return; + const existing = state.byKey[keyOf(p)]; + if (!existing) return; + applyLatestChapterIfChanged( + existing, + computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId) + ); + } + + // Everything else is only learned by fetching a series page — or, where the + // site offers one, its JSON API. Same-origin only: these requests carry the + // session that gets us past the site's bot checks, which a request to the + // other site (or from a server) would not. A few series per navigation keeps + // it indistinguishable from browsing. + async function backgroundRefreshLatest() { + const site = currentSite(); + if (!site) return; + + const checked = loadLastChecked(); + const now = Date.now(); + const due = state.list + .filter((b) => b.site === site && b.series_url) + .filter((b) => statusOf(b) !== "finished") + .filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS) + .sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0)) + .slice(0, LATEST_CHECK_BATCH); + if (due.length === 0) return; + + const adapter = adapterFor(site); + for (const bm of due) { + // Recorded even when the fetch fails, so a broken series is retried on + // the next throttle window rather than on every single page load. + checked[bm.key] = Date.now(); + try { + const res = await fetch(bm.series_url, { credentials: "same-origin" }); + if (!res.ok) continue; + const html = await res.text(); + const latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id); + await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest); + } catch (e) { + /* offline or blocked — try again after the throttle window */ + } + } + saveLastChecked(checked); + } + + async function removeBookmark(key) { + removeLocal(key); // optimistic + render(); + if (await pushDelete(key)) toast("Removed"); + else toast("Removed — will sync when online", true); + } + + // Dwell timer: don't advance progress the instant a newer chapter opens (guards + // against a misclick on "latest chapter"). Arm a 25s timer instead; the tab fills + // bottom-up to show it counting down. The manual "Update to X" button still fires + // immediately. Timer is keyed to the chapter, not the URL, so turning pages within + // the same chapter (Demonic /chapter/N/<page>) keeps it counting rather than resetting. + const DWELL_MS = 25000; + let dwell = null; // { id, timerId } + + function chapterIdentity(p) { + return p && p.type === "chapter" ? p.site + ":" + p.seriesId + "@" + p.chapterNum : null; + } + + function armDwell(id) { + dwell = { + id, + timerId: setTimeout(() => { + dwell = null; + stopRing(); + updateToCurrentChapter(); + }, DWELL_MS), + }; + startRing(DWELL_MS); + } + + function cancelDwell() { + if (dwell) clearTimeout(dwell.timerId); + dwell = null; + stopRing(); + } + + // Auto-record on opening a chapter of a bookmarked series (after the dwell delay). + function maybeAutoUpdate() { + const p = state.page; + const id = chapterIdentity(p); + if (dwell && id === dwell.id) return; // same chapter, page turn — keep timer running + cancelDwell(); // navigated elsewhere — drop any stale dwell + if (p.type !== "chapter") return; + const existing = state.byKey[keyOf(p)]; + if (!existing) return; // only track series the user bookmarked + + const cur = p.chapterNum; + const prev = existing.last_chapter_num; + // Advance when current >= stored, or when either is unparseable (set current). + const advance = cur == null || prev == null || cur >= prev; + if (!advance) return; + if (existing.last_chapter_url === p.chapterUrl && existing.last_chapter === p.chapterLabel) { + return; // already recorded + } + armDwell(id); + } + + // ============================================================ + // UI — Shadow DOM (isolated from site CSS) + // ============================================================ + + let root, panelOpen = false; + // True while refresh() is waiting on the backend. Only changes what the list + // draws when there is nothing cached to draw instead. + let loading = false; + let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design + + function buildUI() { + const host = document.createElement("div"); + host.id = "mangabm-host"; + host.style.cssText = "all: initial;"; + (document.body || document.documentElement).appendChild(host); + root = host.attachShadow({ mode: "open" }); + + const style = document.createElement("style"); + style.textContent = CSS; + root.appendChild(style); + + const wrap = document.createElement("div"); + wrap.id = "wrap"; + wrap.innerHTML = TEMPLATE; + root.appendChild(wrap); + + const fab = root.getElementById("fab"); + fab.addEventListener("click", () => { + if (fab.dataset.dragged) { + delete fab.dataset.dragged; // this click is the tail of a drag — swallow it + return; + } + togglePanel(); + }); + root.getElementById("backdrop").addEventListener("click", togglePanel); + root.getElementById("closeBtn").addEventListener("click", togglePanel); + root.getElementById("pending").addEventListener("click", () => drain()); + + for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) { + root.getElementById(id).addEventListener("click", () => { + activeTab = tab; + render(); + }); + } + + placeFab(fab); + makeDraggable(fab); + window.addEventListener("resize", () => placeFab(fab)); // re-clamp on rotation + } + + // ============================================================ + // FAB placement + dragging (snaps to nearest left/right edge) + // ============================================================ + + const FAB_KEY = STORE_PREFIX + "fabpos"; + const FAB_MARGIN = 12; // vertical breathing room at the top and bottom + const FAB_EDGE = 0; // horizontal: an edge tab sits flush against the side + const ARM_MS = 400; // hold this long on the invisible strip to arm a drag + + function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); + } + + function applyFabPos(fab, side, y) { + fab.style.top = y + "px"; + fab.style.bottom = "auto"; + fab.classList.toggle("left", side === "left"); + if (side === "left") { + fab.style.left = FAB_EDGE + "px"; + fab.style.right = "auto"; + } else { + fab.style.right = FAB_EDGE + "px"; + fab.style.left = "auto"; + } + } + + function loadFabPos() { + try { + return JSON.parse(localStorage.getItem(FAB_KEY)); + } catch (e) { + return null; + } + } + + function saveFabPos(side, yRatio) { + try { + localStorage.setItem(FAB_KEY, JSON.stringify({ side, yRatio })); + } catch (e) { + /* quota / private mode — ignore */ + } + } + + function placeFab(fab) { + const h = fab.offsetHeight || 44; + const p = loadFabPos(); + const maxY = window.innerHeight - h - FAB_MARGIN; + const side = p && p.side === "left" ? "left" : "right"; + const y = p ? clamp(p.yRatio * window.innerHeight, FAB_MARGIN, maxY) : maxY - 8; + applyFabPos(fab, side, y); + } + + // A gesture that starts on the visible sliver drags immediately, as it always + // has. A gesture that starts on the invisible #hit strip scrolls the page + // instead — unless it is held for ARM_MS first, which arms a reposition drag. + // ponytail: the scroll is hand-rolled (no momentum/fling) and assumes the + // document is the scroller. That is the price of touch-action:none, which we + // need so a hold can claim pixels a swipe would otherwise scroll. If the + // stiffness annoys, give #hit touch-action:pan-y and accept that vertical + // repositioning must start on the 7px sliver. + function makeDraggable(fab) { + let down = false, moved = false, armed = false, scrolling = false, fromHit = false; + let sx = 0, sy = 0, offX = 0, offY = 0, lastY = 0, armTimer = 0; + + function reset() { + down = moved = armed = scrolling = fromHit = false; + clearTimeout(armTimer); + fab.classList.remove("armed"); + fab.style.transition = ""; + } + + function scrollPage(e) { + e.preventDefault(); + window.scrollBy(0, lastY - e.clientY); + lastY = e.clientY; + } + + fab.addEventListener("pointerdown", (e) => { + delete fab.dataset.dragged; // stale flag would swallow this gesture's click + down = true; + moved = false; + armed = false; + scrolling = false; + fromHit = e.target.id === "hit"; + sx = e.clientX; + sy = e.clientY; + lastY = e.clientY; + const r = fab.getBoundingClientRect(); + offX = e.clientX - r.left; + offY = e.clientY - r.top; + fab.setPointerCapture(e.pointerId); + fab.style.transition = "none"; + clearTimeout(armTimer); + armTimer = setTimeout(() => { + armed = true; + fab.classList.add("armed"); // visible confirmation the hold took + }, ARM_MS); + }); + + fab.addEventListener("pointermove", (e) => { + if (!down) return; + if (scrolling) { + scrollPage(e); + return; + } + if (!moved) { + if (Math.abs(e.clientX - sx) <= 6 && Math.abs(e.clientY - sy) <= 6) return; + clearTimeout(armTimer); + if (fromHit && !armed) { + scrolling = true; + scrollPage(e); + return; + } + moved = true; + } + e.preventDefault(); + const w = fab.offsetWidth, h = fab.offsetHeight; + fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - w) + "px"; + fab.style.right = "auto"; + fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px"; + fab.style.bottom = "auto"; + }); + + function snapToEdge() { + const w = fab.offsetWidth, h = fab.offsetHeight; + const r = fab.getBoundingClientRect(); + const side = r.left + w / 2 < window.innerWidth / 2 ? "left" : "right"; + const y = clamp(r.top, FAB_MARGIN, window.innerHeight - h - FAB_MARGIN); + fab.style.transition = "left .2s ease, right .2s ease, top .2s ease"; + applyFabPos(fab, side, y); + saveFabPos(side, y / window.innerHeight); + } + + fab.addEventListener("pointerup", (e) => { + if (!down) return; + const didDrag = moved, didScroll = scrolling; + reset(); + if (didScroll) { + fab.dataset.dragged = "1"; // suppress any click trailing the scroll + return; + } + if (!didDrag) return; // tap — let the click handler toggle the panel + fab.dataset.dragged = "1"; // suppress the click that trails this drag + snapToEdge(); + }); + + fab.addEventListener("pointercancel", () => { + if (!down) return; + const didDrag = moved; + reset(); + if (didDrag) snapToEdge(); // OS claimed the gesture mid-drag — don't strand the FAB + }); + + // touch-action: none stops panning and zooming but NOT Chromium's long-press + // gesture, which fires at ~500ms — just after ARM_MS — raises the context + // menu, and cancels the pointer stream. The hold armed the drag and the OS + // stole it back a frame later. Suppressing the menu keeps the pointer alive. + fab.addEventListener("contextmenu", (e) => e.preventDefault()); + } + + // ============================================================ + // Dwell-timer progress: the tab fills bottom-up. A 7px sliver has no room + // for the ring this replaced. + // ============================================================ + + function startRing(ms) { + if (!root) return; + const fill = root.getElementById("fill"); + fill.style.transition = "none"; + fill.style.height = "0"; + fill.getBoundingClientRect(); // reflow so the next assignment animates + fill.style.transition = "height " + ms + "ms linear"; + fill.style.height = "100%"; + } + + function stopRing() { + if (!root) return; + const fill = root.getElementById("fill"); + if (!fill) return; + fill.style.transition = "none"; + fill.style.height = "0"; + } + + function togglePanel() { + panelOpen = !panelOpen; + render(); + if (panelOpen) refresh(); // pull latest from server on open + } + + function el(tag, props, children) { + const e = document.createElement(tag); + if (props) for (const k in props) { + if (k === "text") e.textContent = props[k]; + else if (k === "html") e.innerHTML = props[k]; + else if (k.slice(0, 2) === "on") e.addEventListener(k.slice(2).toLowerCase(), props[k]); + else e.setAttribute(k, props[k]); + } + if (children) for (const c of children) if (c) e.appendChild(c); + return e; + } + + function render() { + if (!root) return; + root.getElementById("panel").classList.toggle("open", panelOpen); + root.getElementById("backdrop").classList.toggle("open", panelOpen); + + // Silent while everything is synced; honest the moment something is stuck. + const pending = root.getElementById("pending"); + pending.hidden = queueCount() === 0; + pending.textContent = "⟳ " + queueCount() + " pending"; + + // Context header for the current page. + const ctx = root.getElementById("context"); + ctx.innerHTML = ""; + const p = state.page; + if (p.type === "series" || p.type === "chapter") { + const key = keyOf(p); + const existing = state.byKey[key]; + ctx.appendChild(el("div", { class: "ctx-title", text: p.title || p.seriesId })); + if (!existing) { + ctx.appendChild(el("button", { class: "btn primary", text: "+ Bookmark this", onclick: () => bookmarkCurrent() })); + } else { + const cur = existing.last_chapter || "—"; + ctx.appendChild(el("div", { class: "ctx-sub", text: "Tracked · last read: " + cur })); + if (p.type === "chapter" && p.chapterLabel && p.chapterLabel !== existing.last_chapter) { + ctx.appendChild(el("button", { class: "btn primary", text: "Update to " + p.chapterLabel, onclick: () => updateToCurrentChapter() })); + } + } + } else { + ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." })); + } + + // Tabs narrow what is drawn; state.list always holds every bookmark. + // Archived rows are hidden from All and Favourites, and finished ones — + // which only the web UI can set — are hidden from every tab. + for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) { + root.getElementById(id).classList.toggle("active", activeTab === tab); + } + + const listEl = root.getElementById("list"); + listEl.innerHTML = ""; + const items = state.list.filter((b) => { + const s = statusOf(b); + if (activeTab === "archived") return s === "archived"; + if (s !== "reading") return false; + return activeTab !== "favorites" || b.favorite; + }); + if (items.length === 0 && loading) { + // Nothing cached and the fetch is still out — without this the panel + // looks frozen on the first open after a cold start. + listEl.appendChild(el("div", { class: "empty" }, [el("div", { class: "spinner" })])); + } else if (items.length === 0) { + const empty = { + favorites: "No favourites yet.", + archived: "Nothing archived.", + all: "No bookmarks yet.", + }[activeTab]; + listEl.appendChild(el("div", { class: "empty", text: empty })); + } + for (const b of items) { + listEl.appendChild(renderItem(b)); + } + } + + function renderItem(b) { + const cont = b.last_chapter_url || b.series_url || "#"; + // Only worth naming the latest chapter while it is ahead of the read one. + const behind = + b.latest_chapter && + b.latest_chapter_num != null && + (b.last_chapter_num == null || b.latest_chapter_num > b.last_chapter_num); + const sub = behind + ? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter + : (b.last_chapter || "—") + " · " + b.site; + // Cover and title are the only continue links. The subtitle and the action + // row live in the same text column but are not navigation targets — a thumb + // that misses ★ must not land on one, and Remove must never be one. + // hot = a chapter is out that has not been read (crimson title, ember rule); + // dim = archived (italic grey). Same two states the web UI paints. + const heat = (behind ? " hot" : "") + (statusOf(b) === "archived" ? " dim" : ""); + return el("div", { class: "item" + heat }, [ + el("a", { class: "go", href: cont }, [ + b.cover + ? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" }) + : el("div", { class: "cover ph" }), + ]), + el("div", { class: "meta" }, [ + el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }), + el("div", { class: "c" + (behind ? " behind" : ""), text: sub }), + el("div", { class: "actions" }, [ + el("button", { + class: "btn small star" + (b.favorite ? " on" : ""), + text: b.favorite ? "★" : "☆", + title: b.favorite ? "Remove from favourites" : "Add to favourites", + onclick: () => toggleFavorite(b.key), + }), + el("button", { + class: "btn small", + text: statusOf(b) === "archived" ? "Unarchive" : "Archive", + onclick: () => toggleArchive(b.key), + }), + el("button", { + class: "btn small danger", + text: "Remove", + onclick: () => confirmRemove(b), + }), + ]), + ]), + ]); + } + + // Removal syncs to every device, so it asks first. confirm() is the page's + // own dialog, not a shadow-root one — modal, and the site cannot style it. + function confirmRemove(b) { + if (confirm('Remove "' + (b.title || b.series_id) + '" from your bookmarks?')) { + removeBookmark(b.key); + } + } + + // Toast inside the shadow root. + function toast(msg, isErr) { + if (!root) return; + const t = el("div", { class: "toast" + (isErr ? " err" : ""), text: msg }); + root.getElementById("toasts").appendChild(t); + setTimeout(() => t.classList.add("show"), 10); + setTimeout(() => { + t.classList.remove("show"); + setTimeout(() => t.remove(), 300); + }, 2600); + } + + // ============================================================ + // Refresh + navigation + // ============================================================ + + async function refresh() { + await drain(); // push what we owe before adopting the server's view of it + loading = true; + render(); + try { + const list = await apiGet(); + setList(overlayPending(list)); + } catch (e) { + // Offline or backend down: keep whatever the cache holds. + } finally { + loading = false; + render(); + } + } + + let lastUrl = location.href; + function onNavigate() { + state.page = detect(); + render(); + maybeAutoUpdate(); + maybeCaptureLatestOnSeriesPage(); + // Asura is client-routed, so full loads are rare — checking here too is + // what keeps its bookmarks current. The throttle still caps the rate. + backgroundRefreshLatest(); + // Drain only, not a full refresh: Asura may go a long time without a + // reload, and an extra GET per client-side route change is not wanted. + drain(); + } + + // Framework-agnostic URL-change watcher: patch history + poll as a fallback, + // so it works whether the site is client-routed (Asura) or reloads (Demonic). + function installNavWatcher() { + const fire = () => { + if (location.href !== lastUrl) { + lastUrl = location.href; + // Meta tags update slightly after the URL on client routes. + setTimeout(onNavigate, 300); + } + }; + const wrap = (name) => { + const orig = history[name]; + history[name] = function () { + const r = orig.apply(this, arguments); + fire(); + return r; + }; + }; + wrap("pushState"); + wrap("replaceState"); + window.addEventListener("popstate", fire); + setInterval(fire, 1500); // catch routes that bypass history + } + + // ============================================================ + // Two-finger long-press — a way in that does not depend on finding a 7px tab + // + // Deliberately two fingers, not one: Chromium claims the one-finger long + // press for image context menus, text selection, and link previews, and on a + // manga reader the whole viewport is images. It assigns no two-finger long + // press, so these listeners stay passive and never preventDefault. + // ============================================================ + + const LONGPRESS_MS = 500; + const LONGPRESS_SLOP = 15; // px either finger may drift before it counts as a drag + + function installLongPress() { + let timer = null; + let start = null; // [{x, y}, {x, y}] — where each finger landed + + const cancel = () => { + if (timer) clearTimeout(timer); + timer = null; + start = null; + }; + + const opts = { passive: true, capture: true }; + + // Fires once per finger, so the first touch cancels and the second arms. + // A third finger cancels again. + document.addEventListener( + "touchstart", + (e) => { + cancel(); + if (e.touches.length !== 2) return; + start = [ + { x: e.touches[0].clientX, y: e.touches[0].clientY }, + { x: e.touches[1].clientX, y: e.touches[1].clientY }, + ]; + timer = setTimeout(() => { + cancel(); + togglePanel(); + }, LONGPRESS_MS); + }, + opts + ); + + // A pinch-zoom is two fingers held down, so the slop check is what keeps a + // normal-speed one from opening the panel mid-gesture. Verified 2026-07-28: + // a pinch that reaches 240px in ~240ms cancels well before the timer. A + // deliberately slow pinch that stays inside the slop for the full 500ms + // does open the panel — accepted, since an absolute per-finger drift + // threshold cannot tell that apart from two fingers resting, which is the + // gesture this is for. + document.addEventListener( + "touchmove", + (e) => { + if (!start || e.touches.length !== 2) return cancel(); + for (let i = 0; i < 2; i++) { + if ( + Math.abs(e.touches[i].clientX - start[i].x) > LONGPRESS_SLOP || + Math.abs(e.touches[i].clientY - start[i].y) > LONGPRESS_SLOP + ) { + return cancel(); + } + } + }, + opts + ); + + document.addEventListener("touchend", cancel, opts); + document.addEventListener("touchcancel", cancel, opts); + } + + // ============================================================ + // Boot + // ============================================================ + + function init() { + buildUI(); + state.page = detect(); + render(); + installNavWatcher(); + installLongPress(); + window.addEventListener("online", drain); // signal returned while the page stayed open + // Sync first: both auto-record and the latest-chapter checks below need to + // know which series are bookmarked and how fresh they are. + refresh().then(() => { + maybeAutoUpdate(); + maybeCaptureLatestOnSeriesPage(); + backgroundRefreshLatest(); + }); + } + + // ============================================================ + // Markup + styles (kept at bottom for readability) + // ============================================================ + + const TEMPLATE = ` + <button id="fab" aria-label="Novel bookmarks"><span id="fill"></span><span id="hit"></span></button> + <div id="backdrop"></div> + <aside id="panel" role="dialog" aria-label="Novel bookmarks"> + <header> + <span class="brand"> + <svg class="mark" viewBox="0 0 200 172" aria-hidden="true"> + <g fill="var(--ink)" stroke="currentColor" stroke-width="6.5" stroke-linejoin="round" stroke-linecap="round"> + <path fill="none" d="M28 36H4v114h192V36h-24"></path> + <path fill="none" d="M28 23H17v127h166V23h-11"></path> + <g id="mb-half"> + <path d="M28 7 88 55v97L28 138z"></path> + <g fill="currentColor" stroke="none"> + <path d="M37 25 55 39v41L37 66z"></path> + <path d="M60 42 79 57v42L60 84z"></path> + <path d="M37 75 79 108v13L37 88z"></path> + <path d="M37 98 79 129v11L37 131z"></path> + </g> + </g> + <use href="#mb-half" transform="matrix(-1 0 0 1 200 0)"></use> + <g stroke="var(--ember)"> + <path d="M100 4l9 5v11l-9 5-9-5V9z"></path> + <path d="M94 24h12v24H94z"></path> + <path d="M70 47h60v14H70z"></path> + <path d="M91 61h18v87l-9 20-9-20z"></path> + </g> + </g> + </svg> + <span>Bookmark<em>Manager</em></span> + </span> + <button id="closeBtn" aria-label="Close">close</button> + </header> + <div id="nav"> + <a class="chip" href="${WEB_BASE}/?lib=novel" target="_blank" rel="noopener">Web</a> + <a class="chip" href="https://novelfull.com" target="_blank" rel="noopener">NovelFull</a> + <a class="chip" href="https://lightnovelworld.net" target="_blank" rel="noopener">LNW</a> + <button id="pending" class="chip pending" hidden>⟳ 0 pending</button> + </div> + <section id="context"></section> + <div id="tabs" role="tablist"> + <button id="tabAll" class="tab active" role="tab">All</button> + <button id="tabFav" class="tab" role="tab">Favourites</button> + <button id="tabArc" class="tab" role="tab">Archived</button> + </div> + <div id="list"></div> + </aside> + <div id="toasts"></div> + `; + + const CSS = ` + /* Cinder, ported to the panel: same tokens and the same rule that heat is + typographic. Fonts are system stacks, not the web UI's Google fonts — an + @import here would be at the mercy of the host page's CSP. + See docs/design-system.md. */ + :host { + all: initial; + --ink: #100f0e; --ash: #161413; --dim: #0d0c0b; + --rule: #221f1d; --field-line: #2c2926; --hover: #1a1816; + --paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb; + --mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b; + --ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907; + --ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450; + --font-display: Georgia, "Times New Roman", serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; + --font-body: system-ui, -apple-system, sans-serif; + } + * { box-sizing: border-box; font-family: var(--font-body); } + #fab { + position: fixed; + width: 7px; height: 44px; border: none; padding: 0; + background: var(--ember); opacity: .5; + box-shadow: 0 2px 8px rgba(0,0,0,.5); z-index: 2147483646; cursor: pointer; + touch-action: none; user-select: none; -webkit-touch-callout: none; + } + #fab:active, #fab.armed { opacity: 1; } + #fab.armed { box-shadow: 0 0 0 2px var(--ember-soft); } + #fill { + position: absolute; left: 0; right: 0; bottom: 0; height: 0; + background: var(--paper); pointer-events: none; + } + /* Invisible tap extension: the visible sliver is 7x44, this makes it 28x72. + It grows inward (toward the screen centre) because the tab is docked flush + against an edge, so outward is off-screen. */ + #hit { + position: absolute; top: -14px; bottom: -14px; + left: -21px; right: 0; + } + #fab.left #hit { left: 0; right: -21px; } + #backdrop { + position: fixed; inset: 0; background: rgba(0,0,0,.6); + opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646; + } + #backdrop.open { opacity: 1; pointer-events: auto; } + #panel { + position: fixed; top: 0; right: 0; height: 100%; + width: min(400px, 92vw); background: var(--ink); color: var(--paper-dim); + transform: translateX(105%); transition: transform .25s ease; + z-index: 2147483647; display: flex; flex-direction: column; + padding-bottom: env(safe-area-inset-bottom, 0px); + border-left: 1px solid var(--rule); + box-shadow: -4px 0 20px rgba(0,0,0,.6); + } + #panel.open { transform: translateX(0); } + header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px; border-bottom: 1px solid var(--rule); + font: 400 22px/1 var(--font-display); color: var(--paper); + } + header em { color: var(--ember); font-style: italic; } + .brand { display: flex; align-items: center; gap: 9px; } + /* 5px stroke on a 200-unit grid thins out at this size: the markup carries + 6.5 instead. overflow visible keeps the topmost blade pip from clipping. */ + .mark { width: 26px; height: 22px; flex: none; overflow: visible; } + #closeBtn { + background: none; border: none; color: var(--mute); cursor: pointer; + font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase; + border-bottom: 1px solid var(--field-line); padding: 0 0 2px; + } + #closeBtn:active { color: var(--paper); } + #nav { display: flex; gap: 14px; padding: 11px 16px; border-bottom: 1px solid var(--rule); } + /* Chips are set as small caps rules, not pills — the sheet has no corners. */ + .chip { + background: none; border: none; padding: 0 0 2px; + border-bottom: 1px solid var(--field-line); + color: var(--mute); text-decoration: none; cursor: pointer; + font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase; + white-space: nowrap; + } + .chip:active { color: var(--paper); border-bottom-color: var(--paper); } + .chip.pending { color: var(--ember); border-bottom-color: var(--ember); } + .chip.pending[hidden] { display: none; } + #context { + padding: 14px 16px; border-bottom: 1px solid var(--rule); + display: flex; flex-direction: column; gap: 9px; align-items: flex-start; + } + .ctx-title { font: 400 19px/1.2 var(--font-display); color: var(--paper); } + .ctx-sub { + font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; + text-transform: uppercase; color: var(--mute); + } + #tabs { display: flex; gap: 16px; padding: 2px 16px 0; border-bottom: 1px solid var(--rule); } + .tab { + background: none; border: none; cursor: pointer; color: var(--mute); + font: 400 16px var(--font-display); padding: 8px 0 12px; + border-bottom: 2px solid transparent; margin-bottom: -1px; + } + .tab.active { color: var(--paper); border-bottom-color: var(--paper); } + #list { overflow-y: auto; flex: 1; } + .empty { + color: var(--mute); padding: 34px 16px; + font: 400 15px/1.6 var(--font-body); + } + /* Loading is the web UI's burning hairline, laid flat across the list. */ + .spinner { + width: 100%; height: 1px; background: var(--rule); overflow: hidden; position: relative; + } + .spinner::after { + content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 30%; + background: var(--ember); animation: barSlide 1.15s linear infinite; + } + @keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } } + .item { + display: flex; flex-direction: row; gap: 12px; + padding: 14px 16px; border-bottom: 1px solid var(--rule); + } + .item.dim { background: var(--dim); } + .go { display: block; flex: none; } + .go:active, .go-t:active { opacity: .7; } + .go-t { display: block; text-decoration: none; color: inherit; } + .cover { + width: 52px; height: 70px; object-fit: cover; flex: none; + background: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px); + } + .cover.ph { display: block; } + .item.dim .cover { filter: grayscale(1); opacity: .85; } + .meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; } + .t { + font: 400 17px/1.2 var(--font-display); color: var(--paper-dim); + max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; } + .item.dim .t { font-style: italic; color: var(--mute); } + .c { + margin: 0; color: var(--mute); + font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; + } + .c.behind { color: var(--ember); } + .actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; } + .btn { + background: none; color: var(--mute); border: 1px solid var(--rule); + margin: 0 -1px -1px 0; padding: 0 12px; height: 38px; line-height: 36px; + cursor: pointer; text-decoration: none; display: inline-block; + font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase; + } + .btn:active { color: var(--paper); background: var(--hover); } + .btn.primary { + border-color: var(--paper); background: var(--paper); color: var(--ink); + font: 400 16px var(--font-display); letter-spacing: 0; text-transform: none; + height: 44px; line-height: 42px; padding: 0 18px; + } + .btn.small { height: 34px; line-height: 32px; padding: 0 10px; } + .btn.danger { color: var(--trash); } + .btn.danger:active { color: var(--ember); background: var(--ember-wash); } + .btn.star { font-size: 13px; letter-spacing: 0; } + .btn.star.on { color: var(--brass); border-color: #332b14; } + #toasts { + position: fixed; left: 50%; transform: translateX(-50%); + bottom: calc(80px + env(safe-area-inset-bottom, 0px)); + z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center; + } + .toast { + background: var(--ash); color: var(--paper); padding: 12px 16px; + border-left: 2px solid var(--paper); + font: 400 16px var(--font-display); + opacity: 0; transform: translateY(10px); transition: all .3s; + box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 80vw; + } + .toast.show { opacity: 1; transform: translateY(0); } + .toast.err { background: var(--ember-wash); border-left-color: var(--ember); color: var(--ember-soft); } + `; + + // ============================================================ + // Test hook + // + // Fires only in Node, where window is undefined. The typeof window check keeps it + // inert on pages whose own scripts define a UMD module shim — under @grant none + // the userscript shares page globals, so we must not clobber the page's exports. + // Exposes pure logic only — see userscript/test/novel-logic.test.js. + // ============================================================ + if (typeof window === "undefined" && typeof module === "object" && module.exports) { + module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe }; + } + + // ============================================================ + // Boot — MUST run last: init() -> buildUI() reads TEMPLATE/CSS, + // which are const (TDZ) and only initialized above. At + // @run-at document-idle document.body already exists, so init() + // fires synchronously here, after the consts are set. + // ============================================================ + if (document.body) init(); + else document.addEventListener("DOMContentLoaded", init); +})(); diff --git a/userscript/test/logic.test.js b/userscript/test/logic.test.js index f6b96c3..73230d0 100644 --- a/userscript/test/logic.test.js +++ b/userscript/test/logic.test.js @@ -60,6 +60,7 @@ const { kagane, anchorsFromHTML, statusOf, + kindOf, } = require("../manga-bookmark.user.js"); // detect() reads only these four properties off location. @@ -377,3 +378,17 @@ test("statusOf defaults a missing status to reading", () => { assert.equal(statusOf({ status: "archived" }), "archived"); assert.equal(statusOf({ status: "finished" }), "finished"); }); + +// ============================================================ +// kindOf — a row written before the kind column existed has none, and every +// one of those is manga. +// ============================================================ + +test("kindOf defaults a missing kind to manga", () => { + assert.equal(kindOf({}), "manga"); + assert.equal(kindOf({ kind: "" }), "manga"); +}); + +test("kindOf passes through an explicit kind", () => { + assert.equal(kindOf({ kind: "novel" }), "novel"); +}); diff --git a/userscript/test/novel-logic.test.js b/userscript/test/novel-logic.test.js new file mode 100644 index 0000000..7293764 --- /dev/null +++ b/userscript/test/novel-logic.test.js @@ -0,0 +1,182 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert"); + +// ============================================================ +// Minimal browser stub. Same shape as logic.test.js, plus a meta[name=...] +// branch: novelfull ships no og: tags, so its cover comes from name="image". +// document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded +// that never fires and no network call is ever made. +// ============================================================ + +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; + +globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" }; + +let metaTags = {}; +let namedMetas = {}; +let elements = {}; +globalThis.document = { + querySelector(sel) { + let m = sel.match(/^meta\[property="([^"]+)"\]$/); + if (m) { + const v = metaTags[m[1]]; + return v == null ? null : { getAttribute: () => v }; + } + m = sel.match(/^meta\[name="([^"]+)"\]$/); + if (m) { + const v = namedMetas[m[1]]; + return v == null ? null : { getAttribute: () => v }; + } + const text = elements[sel]; + return text == null ? null : { textContent: text }; + }, + querySelectorAll() { + return []; + }, + addEventListener() {}, + body: undefined, +}; + +const { + novelfull, + lightnovelworld, + kindOf, + maxChapter, +} = require("../novel-bookmark.user.js"); + +function loc(href) { + const u = new URL(href); + return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname }; +} + +function reset() { + metaTags = {}; + namedMetas = {}; + elements = {}; +} + +// ============================================================ +// novelfull adapter +// ============================================================ + +test("novelfull.detect reads a series page", () => { + reset(); + namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" }; + elements = { "h3.title": "Reverend Insanity" }; + const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html")); + assert.equal(p.type, "series"); + assert.equal(p.site, "novelfull"); + assert.equal(p.seriesId, "reverend-insanity"); + assert.equal(p.title, "Reverend Insanity"); + assert.equal(p.cover, "https://novelfull.com/uploads/thumbs/ri.jpg"); + assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html"); + assert.equal(p.chapterNum, null); +}); + +test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => { + reset(); + namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" }; + elements = { "a.truyen-title": "Reverend Insanity" }; + const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html"; + const p = novelfull.detect(loc(url)); + assert.equal(p.type, "chapter"); + assert.equal(p.seriesId, "reverend-insanity"); + assert.equal(p.chapterNum, 2334); + assert.equal(p.chapterLabel, "Chapter 2334"); + assert.equal(p.chapterUrl, url); + assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html"); + assert.equal(p.title, "Reverend Insanity"); +}); + +test("novelfull.detect returns other for non-series paths", () => { + reset(); + assert.equal(novelfull.detect(loc("https://novelfull.com/")).type, "other"); + assert.equal(novelfull.detect(loc("https://novelfull.com/genre/Fantasy")).type, "other"); +}); + +test("novelfull.latestChapterFromAnchors takes the max and ignores other series", () => { + const best = novelfull.latestChapterFromAnchors( + [ + { href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" }, + { href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" }, + { href: "/reverend-insanity/chapter-2.html", text: "Chapter 2" }, + { href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" }, + ], + "reverend-insanity" + ); + assert.deepEqual(best, { num: 2334, label: "Chapter 2334" }); +}); + +// ============================================================ +// lightnovelworld adapter +// ============================================================ + +test("lightnovelworld.detect reads a series page", () => { + reset(); + metaTags = { "og:image": "https://lightnovelworld.net/wp-content/uploads/awe.webp" }; + elements = { "h1.entry-title": "A Will Eternal" }; + const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/")); + assert.equal(p.type, "series"); + assert.equal(p.site, "lightnovelworld"); + assert.equal(p.seriesId, "a-will-eternal"); + assert.equal(p.title, "A Will Eternal"); + assert.equal(p.cover, "https://lightnovelworld.net/wp-content/uploads/awe.webp"); +}); + +test("lightnovelworld.detect strips the chapter suffix off the heading", () => { + reset(); + elements = { "h1.entry-title": "A Will Eternal Chapter 1298" }; + const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/"; + const p = lightnovelworld.detect(loc(url)); + assert.equal(p.type, "chapter"); + assert.equal(p.seriesId, "a-will-eternal"); + assert.equal(p.chapterNum, 1298); + assert.equal(p.chapterLabel, "Chapter 1298"); + assert.equal(p.title, "A Will Eternal"); + assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/"); + // Chapter pages have no cover; the merge in bookmarkCurrent keeps the stored one. + assert.equal(p.cover, ""); +}); + +test("lightnovelworld.detect returns other for non-series paths", () => { + reset(); + assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other"); + assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/az-lists/")).type, "other"); +}); + +test("lightnovelworld.latestChapterFromAnchors takes the max and ignores other series", () => { + const best = lightnovelworld.latestChapterFromAnchors( + [ + { href: "https://lightnovelworld.net/a-will-eternal-chapter-1/", text: "Chapter 1" }, + { href: "https://lightnovelworld.net/a-will-eternal-chapter-1317/", text: "Chapter 1317" }, + { href: "https://lightnovelworld.net/a-will-eternal-chapter-1298/", text: "Chapter 1298" }, + { href: "https://lightnovelworld.net/overgeared-chapter-9999/", text: "Chapter 9999" }, + ], + "a-will-eternal" + ); + assert.deepEqual(best, { num: 1317, label: "Chapter 1317" }); +}); + +test("latestChapterFromAnchors returns null when nothing matches", () => { + assert.equal(novelfull.latestChapterFromAnchors([{ href: "/about", text: "About" }], "x"), null); + assert.equal(maxChapter([], /chapter-([0-9.]+)/), null); +}); + +// ============================================================ +// kindOf +// ============================================================ + +test("kindOf defaults a missing kind to manga", () => { + assert.equal(kindOf({}), "manga"); +}); + +test("kindOf passes through novel", () => { + assert.equal(kindOf({ kind: "novel" }), "novel"); +});