Compare commits

...

14 Commits

Author SHA1 Message Date
sulthan c545b1b92e chore: address final review must-fix items 2026-08-06 03:34:41 +07:00
sulthan 50e4d6a6e2 feat: serve novel-bookmark.user.js and allow the novel sites' origins 2026-08-06 03:29:24 +07:00
sulthan c9a2fd4614 feat(userscript): add novel-bookmark.user.js for novelfull and lightnovelworld 2026-08-06 03:26:03 +07:00
sulthan 51fcd8732b feat(userscript): stamp and filter by kind in the manga script 2026-08-06 03:22:28 +07:00
sulthan d465028443 feat(web): split the UI into manga and novel libraries 2026-08-06 03:19:49 +07:00
sulthan f24924031e feat(web): apply design snapshot — libswitch, novel site colours, login art 2026-08-06 03:15:36 +07:00
sulthan 92f1fbf6ec feat(latest): poll novelfull via headless browser, lightnovelworld via TLS 2026-08-06 03:10:50 +07:00
sulthan c81e50c7f1 feat(latest): parse newest chapter for novelfull and lightnovelworld 2026-08-06 03:07:22 +07:00
sulthan d9d7a1c00d feat(api): validate kind on PUT /bookmarks 2026-08-06 03:05:14 +07:00
sulthan dcec12ae72 feat(store): add kind column splitting manga and novel libraries 2026-08-06 03:03:43 +07:00
sulthan 44a8df43e1 refactor: rebrand compose, env and docs to BookmarkManager 2026-08-06 02:55:46 +07:00
sulthan d41a1d2c0b refactor: rebrand userscript storage keys, hosts and wordmark 2026-08-06 02:47:33 +07:00
sulthan f30d4cc7cb refactor: rename Go module to bookmarkmanager 2026-08-06 02:44:43 +07:00
sulthan 3149dc0c26 chore: pre-flight baseline for novel-support plan
- .gitignore: drop .superpowers/ and local go.work
- AGENTS.md, docs/design-system.md, backend/AGENTS.md, userscript/AGENTS.md
- login-art.png: 2.8MB design asset, picked up by //go:embed
2026-08-06 02:43:29 +07:00
40 changed files with 2948 additions and 347 deletions
+7 -6
View File
@@ -5,12 +5,13 @@
API_TOKEN=changeme-generate-a-long-random-token API_TOKEN=changeme-generate-a-long-random-token
# Comma-separated origins allowed to call the API (CORS). Both Asura domains # Comma-separated origins allowed to call the API (CORS). Both Asura domains
# plus Demonic, Comix, and Kagane. Add/remove as the sites' hostnames change. # plus Demonic, Comix, Kagane, and the two novel sites. Add/remove as the
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to # 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 --- # --- Prod override (Traefik) only ---
# Subdomain Traefik routes to this service (required by the prod override). # 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". # Traefik's docker network name, if not "proxy".
# PROXY_NETWORK=proxy # PROXY_NETWORK=proxy
# Traefik HTTPS entrypoint + cert resolver names, if yours differ from these. # 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 # TRAEFIK_CERTRESOLVER=le
# --- Web UI --- # --- 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). # disable the web UI entirely (the routes are not registered at all).
# Generate one: openssl rand -base64 18 # Generate one: openssl rand -base64 18
WEB_PASSWORD= WEB_PASSWORD=
@@ -27,8 +28,8 @@ WEB_PASSWORD=
# whether or not WEB_PASSWORD is set). Left commented on purpose: an example # 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 # 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 # 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. # answers on BOOKMARK_API_HOST for the userscript's API.
# MANGA_WEB_HOST=manga.example.com # BOOKMARK_WEB_HOST=bookmark.example.com
# --- Latest-chapter poller --- # --- Latest-chapter poller ---
# The backend re-checks each bookmarked series' newest published chapter on its # The backend re-checks each bookmarked series' newest published chapter on its
+3
View File
@@ -8,6 +8,9 @@ backend/backend
graphify-out/ graphify-out/
plans/ plans/
docs/superpowers/ docs/superpowers/
.superpowers/
go.work
go.work.sum
# impeccable-ignore-start # impeccable-ignore-start
# Ephemeral output, runtime state, and per-dev overrides. # Ephemeral output, runtime state, and per-dev overrides.
+19 -142
View File
@@ -2,149 +2,27 @@
Guidance for OpenCode (and Claude Code) working in this repo. 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 ## 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: Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines:
- **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. - **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()` works **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block). - Cross-origin `fetch()` work **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. - Asura and Demonic are **separate origins with 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. - Userscript run 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. - 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 ## 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) -- 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`. 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`.
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 `<site>:<series_id>` (`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>` (slug carries a trailing
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `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>` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title/<slug>/chapter/<n>/<page>` (older `chaptered.php?manga=<id>&chapter=<n>` 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.
## Commands ## Commands
@@ -155,23 +33,23 @@ Backend (`cd backend`):
Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`). Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`).
Smoke test: `curl` endpoints w/ `Authorization: Bearer <token>`; confirm `OPTIONS` preflight returns CORS headers and `/healthz` returns 200. Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200.
## Forge: Gitea, not GitHub ## 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 <branch> --base main --title "..." --description "..."` - Open PR: `tea pr create --head <branch> --base main --title "..." --description "..."`
- List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>` - List / view / check out: `tea pr list`, `tea pr <n>`, `tea pr checkout <n>`
- Issues: `tea issue create`, `tea issue list` - Issues: `tea issue create`, `tea issue list`
- Auth lives in `tea login`, not `GH_TOKEN` env var. - 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 ## Design system
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md` 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 (`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
`backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript `backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other `TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
@@ -185,7 +63,7 @@ instantly.
## Security invariants ## Security invariants
- Auth on `/bookmarks*`: require `Authorization: Bearer <API_TOKEN>`, **constant-time compare**, 401 otherwise. - Auth on `/bookmarks*`: require `Authorization: Bearer <API_TOKEN>`, **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 ## Comments
@@ -219,15 +97,14 @@ Test: "competent reader get this from code in few sec?" Yes → skip. Needs deto
## graphify ## 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: Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships, `graphify explain "<concept>"` for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. - For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` 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. - 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. - 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). - 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. - Keep comms terse — drop articles, fluff, pleasantries. Code/commits/security written normally.
- `.superpowers/` and `.agents/` dirs hold skill definitions. Gitea at `gitea.violetcrown.my.id`.
+1 -1
View File
@@ -49,7 +49,7 @@ Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTI
## Design system ## Design system
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md` 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 (`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
`backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript `backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other `TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
+24 -24
View File
@@ -10,8 +10,8 @@ ACME/cert resolver, and control a domain.
- Docker + Docker Compose on the server. - Docker + Docker Compose on the server.
- A Traefik instance watching a Docker network (default name assumed: `proxy`). - A Traefik instance watching a Docker network (default name assumed: `proxy`).
- DNS: an `A`/`AAAA` record for `manga-api.<yourdomain>` pointing at the server. - DNS: an `A`/`AAAA` record for `bookmark-api.<yourdomain>` pointing at the server.
- The repo copied to the server, e.g. `/opt/mangabm/` (needs `backend/`, - The repo copied to the server, e.g. `/opt/bookmarkmanager/` (needs `backend/`,
`docker-compose.yml`, `docker-compose.prod.yml`, `.env.example`). `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example`).
Confirm the Traefik network exists (create if not): Confirm the Traefik network exists (create if not):
@@ -25,7 +25,7 @@ docker network ls | grep proxy || docker network create proxy
## 1. Configure `.env` ## 1. Configure `.env`
```bash ```bash
cd /opt/mangabm cd /opt/bookmarkmanager
cp .env.example .env cp .env.example .env
``` ```
@@ -39,10 +39,10 @@ API_TOKEN=<paste output of: openssl rand -hex 32>
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org,https://comix.to,https://kagane.to 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 # 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. # WEB_PASSWORD; see 1b.
MANGA_API_HOST=manga-api.violetcrown.my.id BOOKMARK_API_HOST=bookmark-api.violetcrown.my.id
MANGA_WEB_HOST=manga.violetcrown.my.id BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
# Only if your Traefik setup differs from these defaults: # Only if your Traefik setup differs from these defaults:
# PROXY_NETWORK=proxy # 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. The browser UI is served by the same container on a second hostname.
1. Add a DNS `A`/`AAAA` record for `manga.<yourdomain>` pointing at the server — 1. Add a DNS `A`/`AAAA` record for `bookmark.<yourdomain>` pointing at the server —
the same address as `manga-api.<yourdomain>`. the same address as `bookmark-api.<yourdomain>`.
2. Set both variables in `.env`: 2. Set both variables in `.env`:
```ini ```ini
MANGA_WEB_HOST=manga.violetcrown.my.id BOOKMARK_WEB_HOST=bookmark.violetcrown.my.id
WEB_PASSWORD=<paste output of: openssl rand -base64 18> WEB_PASSWORD=<paste output of: openssl rand -base64 18>
``` ```
@@ -88,16 +88,16 @@ The browser UI is served by the same container on a second hostname.
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build 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. Expected `200`, serving the login page.
Leaving `WEB_PASSWORD` unset is safe: the web routes are not registered and `/` 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 `BOOKMARK_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_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 refuses to start without it even if `WEB_PASSWORD` is unset and the web UI is
otherwise dormant. 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` (no host port, Traefik network + router labels). Always pass **both** `-f`
flags — the prod file is not standalone. 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). 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 `BROWSER_WS_URL`. Missing or unreachable, the poller just skips kagane and
logs it; nothing else is affected. logs it; nothing else is affected.
@@ -126,7 +126,7 @@ Check it's up and healthy:
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps 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 ```bash
# Health (no auth) — must be valid TLS, no cert warning. # 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. # Auth enforced.
curl -s -o /dev/null -w '%{http_code}\n' \ 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) TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
curl -s -H "Authorization: Bearer $TOKEN" \ 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. # CORS preflight from a real site origin.
curl -s -i -X OPTIONS \ curl -s -i -X OPTIONS \
-H 'Origin: https://asurascans.com' \ -H 'Origin: https://asurascans.com' \
-H 'Access-Control-Request-Method: PUT' \ -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) # -> 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`: Edit the config block at the top of `userscript/manga-bookmark.user.js`:
```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 = "<same token as .env>"; const API_TOKEN = "<same token as .env>";
``` ```
@@ -197,7 +197,7 @@ Tampermonkey/Violentmonkey for quick checks before going mobile.
## 6. Smoke-test the full loop ## 6. Smoke-test the full loop
1. Bookmark a series on Asura. 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. on the server — the series should appear.
3. Open a chapter of that series — reopen the panel; last-read updates to that 3. Open a chapter of that series — reopen the panel; last-read updates to that
chapter (auto, never regresses on older chapters). 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 | | 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 <traefik>`. | | No cert / TLS error at the domain | `TRAEFIK_ENTRYPOINT` or `TRAEFIK_CERTRESOLVER` name wrong; or DNS not resolving yet. Check `docker logs <traefik>`. |
| 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). | | `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. | | 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. | | 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): Install once, on the phone (Cromite + Violentmonkey):
``` ```
https://manga-api.<your-domain>/u/<API_TOKEN>/manga-bookmark.user.js https://bookmark-api.<your-domain>/u/<API_TOKEN>/manga-bookmark.user.js
``` ```
Open that URL in Cromite; Violentmonkey offers to install it. The token is in Open that URL in Cromite; Violentmonkey offers to install it. The token is in
+1 -1
View File
@@ -35,7 +35,7 @@ Not a public reading tracker or social app — a private, self-hosted sync layer
## Brand Commitments ## 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. - **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 ## Evidence on Hand
+4 -4
View File
@@ -86,7 +86,7 @@ curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \
### Deploy behind your reverse proxy ### Deploy behind your reverse proxy
Route `https://manga-api.<domain>` → the service on `:8080` (TLS at the proxy). Route `https://bookmark-api.<domain>` → the service on `:8080` (TLS at the proxy).
- **Host proxy** (nginx/Caddy on the host): the base compose already binds - **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;`. `127.0.0.1:8080`; point the proxy `proxy_pass http://127.0.0.1:8080;`.
@@ -99,7 +99,7 @@ Route `https://manga-api.<domain>` → the service on `:8080` (TLS at the proxy)
``` ```
Set `PROXY_NETWORK` in `.env` if your network isn't named `proxy`. Set `PROXY_NETWORK` in `.env` if your network isn't named `proxy`.
Verify: `https://manga-api.<domain>/healthz` returns `ok` over valid TLS (no Verify: `https://bookmark-api.<domain>/healthz` returns `ok` over valid TLS (no
mixed-content), and an `OPTIONS` preflight from a real site origin returns the mixed-content), and an `OPTIONS` preflight from a real site origin returns the
CORS headers. CORS headers.
@@ -112,7 +112,7 @@ CORS headers.
Edit the config block at the top of `userscript/manga-bookmark.user.js`: Edit the config block at the top of `userscript/manga-bookmark.user.js`:
```js ```js
const API_BASE = "https://manga-api.<domain>"; // no trailing slash const API_BASE = "https://bookmark-api.<domain>"; // no trailing slash
const API_TOKEN = "<same token as backend>"; const API_TOKEN = "<same token as backend>";
``` ```
@@ -184,7 +184,7 @@ userscript does the looking, from your own browser session:
(`LATEST_CHECK_BATCH` / `LATEST_CHECK_THROTTLE_MS`). Failures are silent and (`LATEST_CHECK_BATCH` / `LATEST_CHECK_THROTTLE_MS`). Failures are silent and
simply retried after the window. 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. 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 This means a bookmark is as current as its last check — not the moment a
+20 -20
View File
@@ -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 **back up before you pull.** A backup taken after a bad migration is a backup of
the damage. the damage.
Paths below assume the checkout is at `/opt/mangabm`; substitute your own. The Paths below assume the checkout is at `/opt/bookmarkmanager`; substitute your own. The
one absolute rule about paths: **backups live in `../mangabm-backups/`**, a one absolute rule about paths: **backups live in `../bookmarkmanager-backups/`**, a
sibling of the project directory (`/opt/mangabm-backups`), never inside it. It 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 sits outside the repo so `git pull`, `git clean -fd` and a bad `rm -rf` inside
the checkout cannot take the backups with them. the checkout cannot take the backups with them.
``` ```
/opt/ /opt/
├── mangabm/ <- the checkout (this repo) ├── bookmarkmanager/ <- the checkout (this repo)
└── mangabm-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db └── bookmarkmanager-backups/ <- bookmarks-YYYYmmdd-HHMMSS.db
``` ```
--- ---
@@ -26,12 +26,12 @@ the checkout cannot take the backups with them.
## 0. Preflight ## 0. Preflight
```bash ```bash
cd /opt/mangabm cd /opt/bookmarkmanager
# Both -f flags, every time. The prod override is not standalone. # Both -f flags, every time. The prod override is not standalone.
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml" 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 status --short # expect empty
git log --oneline -1 # note this hash — it is your rollback target git log --oneline -1 # note this hash — it is your rollback target
df -h /var/lib/docker | tail -1 # a build needs room 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: Create the backup directory once, and make sure it is a sibling, not a child:
```bash ```bash
mkdir -p ../mangabm-backups mkdir -p ../bookmarkmanager-backups
BACKUP_DIR="$(cd .. && pwd)/mangabm-backups" # absolute — Docker needs it BACKUP_DIR="$(cd .. && pwd)/bookmarkmanager-backups" # absolute — Docker needs it
echo "$BACKUP_DIR" # -> /opt/mangabm-backups echo "$BACKUP_DIR" # -> /opt/bookmarkmanager-backups
``` ```
--- ---
@@ -59,7 +59,7 @@ prefixes it with the project directory:
```bash ```bash
docker volume ls --filter name=bookmarks-data 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) VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
``` ```
@@ -172,11 +172,11 @@ bindmounted read-only and read fresh per request.
```bash ```bash
$COMPOSE ps # Up, and recently (re)created $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 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. 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: Same four API checks as `DEPLOY.md` §3, plus the web UI. Set the host names once:
```bash ```bash
API=https://manga-api.violetcrown.my.id API=https://bookmark-api.violetcrown.my.id
WEB=https://manga.violetcrown.my.id WEB=https://bookmark.violetcrown.my.id
TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2) TOKEN=$(grep -E '^API_TOKEN=' .env | cut -d= -f2)
curl -s $API/healthz # -> ok 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' ls -l /data'
$COMPOSE start $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 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: For a routine redeploy where nothing needs deciding:
```bash ```bash
cd /opt/mangabm cd /opt/bookmarkmanager
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml" 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) VOL=$(docker volume ls --filter name=bookmarks-data -q | head -1)
STAMP=$(date -u +%Y%m%d-%H%M%S) 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 && git pull --ff-only &&
$COMPOSE up -d --build && $COMPOSE up -d --build &&
sleep 5 && 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, 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. | | 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. | | 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. | | 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. | | 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. | | `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). | | Reads work but every write fails after a restore | Restored file is root-owned; the container is uid 65532. `chown 65532:65532` it (§6). |
+82
View File
@@ -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 `<site>:<series_id>` (`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).
+38 -1
View File
@@ -12,7 +12,7 @@ import (
"testing" "testing"
"time" "time"
"mangabm/backend/internal/store" "bookmarkmanager/backend/internal/store"
) )
const testToken = "s3cret-token" const testToken = "s3cret-token"
@@ -470,3 +470,40 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
t.Fatalf("status = %d, want 200", rr.Code) 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)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
module mangabm/backend module bookmarkmanager/backend
go 1.26 go 1.26
+10 -1
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"mangabm/backend/internal/store" "bookmarkmanager/backend/internal/store"
) )
// Handler serves the userscript-facing JSON bookmark API. // Handler serves the userscript-facing JSON bookmark API.
@@ -78,6 +78,15 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
return 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 // Candidate timestamp, not a decision: Upsert keeps the stored one unless
// reading progress actually moved. Any client value is ignored. // reading progress actually moved. Any client value is ignored.
b.UpdatedAt = time.Now().UnixMilli() b.UpdatedAt = time.Now().UnixMilli()
+48 -18
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"regexp" "regexp"
"strings"
"sync" "sync"
"time" "time"
@@ -24,18 +25,23 @@ var kaganeSeriesRe = regexp.MustCompile(`^/series/([0-9a-f-]{36})/?$`)
// BrowserFetcher retrieves pages through a remote headless Chrome over the // BrowserFetcher retrieves pages through a remote headless Chrome over the
// DevTools Protocol. // DevTools Protocol.
// //
// It exists for one reason: kagane.to sits behind a Cloudflare JavaScript // It exists for one reason: kagane.to and novelfull.com sit behind a
// challenge. Verified 2026-08-03 from the deployment host, plain HTTP and // Cloudflare JavaScript challenge. Verified 2026-08-03 (kagane) and 2026-08-05
// bogdanfinn/tls-client with a Chrome_133 profile both get 403 with // (novelfull) from the deployment host, plain HTTP and bogdanfinn/tls-client
// cf-mitigated: challenge on every path, including the API, robots.txt and // with a Chrome_133 profile both get 403 with cf-mitigated: challenge on every
// images. Clearing it requires executing the challenge script, which only a // path, including the API, robots.txt and images. Clearing it requires
// real browser does. // executing the challenge script, which only a real browser does.
// //
// The request is made *inside* the page rather than by extracting cf_clearance // 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 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 // 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 // sync that break silently and separately. The browser's own cookie jar
// persists across polls, so the challenge is solved once every few hours. // 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 { type BrowserFetcher struct {
allocCtx context.Context allocCtx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -73,14 +79,16 @@ func (f *BrowserFetcher) Close() {
f.cancel() f.cancel()
} }
// Get navigates to seriesURL, lets any challenge resolve, then reads the site's // Get navigates to seriesURL, lets any challenge resolve, then reads either the
// JSON API from inside the page so the request carries the clearance cookie. // site's JSON API (kagane) from inside the page so the request carries the
// The returned body is API JSON, which is what latestChapterFrom's kagane case // clearance cookie, or the served HTML itself (novelfull) — see
// expects — it is not HTML. // 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) { func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int, error) {
apiURL, ok := kaganeAPIURL(seriesURL) apiURL, isKagane := kaganeAPIURL(seriesURL)
if !ok { if !isKagane && !novelfullSeriesURL(seriesURL) {
return "", 0, fmt.Errorf("not a fetchable kagane series url: %q", seriesURL) return "", 0, fmt.Errorf("not a fetchable browser series url: %q", seriesURL)
} }
f.mu.Lock() f.mu.Lock()
@@ -101,16 +109,26 @@ func (f *BrowserFetcher) Get(ctx context.Context, seriesURL string) (string, int
}() }()
var body string 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, err := chromedp.Run(tabCtx,
chromedp.Navigate(seriesURL), chromedp.Navigate(seriesURL),
// The challenge reloads the page itself when it passes; waiting for the // 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. // site's own root element is what tells us we are through it.
chromedp.WaitReady("body", chromedp.ByQuery), chromedp.WaitReady("body", chromedp.ByQuery),
chromedp.Evaluate( read,
`fetch(`+jsString(apiURL)+`).then(r => r.ok ? r.text() : "")`,
&body,
awaitPromise,
),
) )
if err != nil { if err != nil {
return "", 0, fmt.Errorf("browser fetch %q: %w", seriesURL, err) 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 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 // awaitPromise makes Evaluate resolve the promise rather than returning a
// serialised Promise object. // serialised Promise object.
func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams {
+21
View File
@@ -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)
}
})
}
}
+27 -12
View File
@@ -6,7 +6,7 @@ import (
"net/url" "net/url"
"time" "time"
"mangabm/backend/internal/store" "bookmarkmanager/backend/internal/store"
) )
// Fetcher retrieves a series page. It exists as an interface so tests can inject // 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 // 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 // fetched at all right now. kagane and novelfull both sit behind a Cloudflare
// challenge that no TLS fingerprint clears — verified 2026-08-03 from the // JavaScript challenge that no TLS fingerprint clears — kagane verified
// deployment host with the same Chrome profile TLSFetcher uses — so it is // 2026-08-03, novelfull verified 2026-08-05, both against the same Chrome_133
// browser-only or nothing. // profile TLSFetcher uses — so they are browser-only or nothing.
func (p *Poller) fetcherFor(site string) Fetcher { func (p *Poller) fetcherFor(site string) Fetcher {
if site == "kagane" { switch site {
case "kagane", "novelfull":
return p.BrowserFetch return p.BrowserFetch
} }
return p.Fetch 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 // 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. // 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 // Three sites are held to a stricter rule, each for a different reason:
// 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 // - kagane and novelfull are fetched by a headless browser, which executes
// non-empty. // 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 { func fetchableSeriesURL(site, seriesURL string) bool {
switch site { switch site {
case "asura", "demonic", "comix", "kagane": case "asura", "demonic", "comix", "kagane", "novelfull", "lightnovelworld":
default: default:
return false return false
} }
@@ -229,8 +235,17 @@ func fetchableSeriesURL(site, seriesURL string) bool {
if u.Scheme != "https" || u.Host == "" { if u.Scheme != "https" || u.Host == "" {
return false return false
} }
if site == "kagane" { switch site {
case "kagane":
return u.Hostname() == "kagane.to" 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 return true
} }
+47 -1
View File
@@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"mangabm/backend/internal/store" "bookmarkmanager/backend/internal/store"
) )
// newTestStore opens a fresh SQLite store in a temp dir. // 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) 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)
}
})
}
}
+36 -1
View File
@@ -1,11 +1,12 @@
package latest package latest
import ( import (
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"mangabm/backend/internal/store" "bookmarkmanager/backend/internal/store"
) )
// latestChapter is the newest chapter a series page advertises. // latestChapter is the newest chapter a series page advertises.
@@ -34,6 +35,17 @@ var comixSlugRe = regexp.MustCompile(`/title/([^/?#]+)`)
// there are no anchors to scan. // there are no anchors to scan.
var kaganeChapterRe = regexp.MustCompile(`"chapter_no":"([0-9.]+)"`) var kaganeChapterRe = regexp.MustCompile(`"chapter_no":"([0-9.]+)"`)
// novelfullSlugRe pulls the series slug out of a stored series_url. novelfull
// series pages are "/<slug>.html"; their chapter anchors are
// "/<slug>/chapter-<n>[-<title-slug>].html". Verified live 2026-08-05.
var novelfullSlugRe = regexp.MustCompile(`^/([^/?#]+)\.html$`)
// lnwSlugRe does the same for lightnovelworld, whose series pages live under
// /novel/<slug>/ while its chapter URLs are flat at the site root:
// "/<slug>-chapter-<n>/", 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 // latestChapterFrom returns the highest chapter number body advertises for this
// series. ok is false when the body yields nothing usable — an unknown site, an // 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, // 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.]+)"`) re = regexp.MustCompile(`"latestChapterUrl":"/title/` + regexp.QuoteMeta(id) + `-[^"]*-chapter-([0-9.]+)"`)
case "kagane": case "kagane":
re = kaganeChapterRe 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: default:
return latestChapter{}, false return latestChapter{}, false
} }
+69
View File
@@ -52,6 +52,33 @@ const kaganeAPIFixture = `
{"book_id":"c","title":"Episode 40.5","chapter_no":"40.5","sort_no":40}]} {"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 = `
<div class="l-chapters">
<a href="/reverend-insanity/chapter-2334-fang-yuan-and-giant-sun.html">Chapter 2334</a>
<a href="/reverend-insanity/chapter-2333-three-venerables.html">Chapter 2333</a>
</div>
<ul class="list-chapter">
<li><a href="/reverend-insanity/chapter-1.html">Chapter 1</a></li>
<li><a href="/reverend-insanity/chapter-2.html">Chapter 2</a></li>
</ul>
<a href="/release-that-witch/chapter-9999.html">Chapter 9999</a>
`
// Trimmed from https://lightnovelworld.net/novel/a-will-eternal/ fetched
// 2026-08-05. Its chapter anchors are absolute and flat — /<slug>-chapter-<n>/
// at the site root, not under /novel/. The last anchor is another series'.
const lnwSeriesFixture = `
<a href="https://lightnovelworld.net/a-will-eternal-chapter-1/">Chapter 1</a>
<a href="https://lightnovelworld.net/a-will-eternal-chapter-1317/">Chapter 1317</a>
<a href="https://lightnovelworld.net/a-will-eternal-chapter-1298/">Chapter 1298</a>
<a href="https://lightnovelworld.net/overgeared-chapter-9999/">Chapter 9999</a>
`
func TestLatestChapterFrom(t *testing.T) { func TestLatestChapterFrom(t *testing.T) {
const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af" const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer" const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer"
@@ -146,6 +173,48 @@ func TestLatestChapterFrom(t *testing.T) {
body: challengeFixture, body: challengeFixture,
wantOK: false, 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 { for _, tt := range tests {
+2 -2
View File
@@ -14,13 +14,13 @@ import (
) )
const ( const (
CookieName = "mangabm_session" CookieName = "bmgr_session"
// 60 days: long enough that a phone stays logged in between reading spells. // 60 days: long enough that a phone stays logged in between reading spells.
sessionTTL = 60 * 24 * time.Hour sessionTTL = 60 * 24 * time.Hour
// Domain separation, so the session key can never collide with any other // Domain separation, so the session key can never collide with any other
// use of the secrets it is derived from. Changing this string logs // use of the secrets it is derived from. Changing this string logs
// everyone out. // everyone out.
sessionKeyPurpose = "mangabm-web-session-v1" sessionKeyPurpose = "bmgr-web-session-v1"
) )
// Key derives the cookie-signing key from both secrets. Sessions are // Key derives the cookie-signing key from both secrets. Sessions are
+31 -12
View File
@@ -33,6 +33,9 @@ type Bookmark struct {
// Archived series stay polled for new chapters; finished ones do not. // Archived series stay polled for new chapters; finished ones do not.
// Empty on the way in means "no opinion" — see Upsert. // Empty on the way in means "no opinion" — see Upsert.
Status string `json:"status"` 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. // HasNewChapter reports whether the site has published past the read point.
@@ -96,6 +99,14 @@ func (b Bookmark) Initial() string {
return "?" 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. // Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
const ( const (
StatusReading = "reading" StatusReading = "reading"
@@ -119,6 +130,7 @@ CREATE TABLE IF NOT EXISTS bookmarks (
latest_chapter_num REAL, latest_chapter_num REAL,
latest_checked_at INTEGER NOT NULL DEFAULT 0, latest_checked_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'reading', status TEXT NOT NULL DEFAULT 'reading',
kind TEXT NOT NULL DEFAULT 'manga',
updated_at INTEGER NOT NULL 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 // Lifecycle bucket. The DEFAULT backfills every pre-existing row as
// 'reading', so there is no separate migration step. // 'reading', so there is no separate migration step.
{"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`}, {"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, const bookmarkColumns = `key, site, series_id, title, series_url, cover,
last_chapter, last_chapter_num, last_chapter_url, 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. // Store is the SQLite-backed bookmark store.
type Store struct { type Store struct {
@@ -296,7 +311,7 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
if err := scan( if err := scan(
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover, &b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
&lastChapter, &lastChapterNum, &lastChapterURL, &lastChapter, &lastChapterNum, &lastChapterURL,
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, &b.Kind,
); err != nil { ); err != nil {
return Bookmark{}, err 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 // 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. // never reaches this clause, so it keeps the fresh timestamp from VALUES.
// //
// The status column resolves on the VALUES side, not in the conflict // The status and kind columns resolve on the VALUES side, not in the
// clause: excluded.* is the row *after* these expressions are evaluated, // conflict clause: excluded.* is the row *after* these expressions are
// so a default applied there would look identical to a real 'reading' and // evaluated, so a default applied there would look identical to a real
// would overwrite an archived row on every PUT from a client that knows // 'reading' / 'manga' and would overwrite an archived or novel row on
// nothing about the column. Resolved once here, an empty incoming status // every PUT from a client that knows nothing about the column. Resolved
// means "keep what is stored", and only a brand-new row falls through to // once here, an empty incoming status or kind means "keep what is
// the literal default. The subquery runs inside this transaction, so it // stored", and only a brand-new row falls through to the literal
// sees the row this statement is about to conflict with. // default. The subquery runs inside this transaction, so it sees the
// row this statement is about to conflict with.
if _, err := tx.Exec(` if _, err := tx.Exec(`
INSERT INTO bookmarks (`+bookmarkColumns+`) INSERT INTO bookmarks (`+bookmarkColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 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 ON CONFLICT(key) DO UPDATE SET
site=excluded.site, series_id=excluded.series_id, title=excluded.title, site=excluded.site, series_id=excluded.series_id, title=excluded.title,
series_url=excluded.series_url, cover=excluded.cover, 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=excluded.latest_chapter,
latest_chapter_num=excluded.latest_chapter_num, latest_chapter_num=excluded.latest_chapter_num,
status=excluded.status, status=excluded.status,
kind=excluded.kind,
updated_at=CASE updated_at=CASE
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
THEN excluded.updated_at 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.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
b.LastChapter, b.LastChapterNum, b.LastChapterURL, b.LastChapter, b.LastChapterNum, b.LastChapterURL,
b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt, 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) return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err)
} }
+101
View File
@@ -660,3 +660,104 @@ func TestDisplayChapter(t *testing.T) {
t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11") 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)
}
}
+2 -2
View File
@@ -46,7 +46,7 @@
// htmx replaces the list on a tab switch, so re-apply to the new cards. // htmx replaces the list on a tab switch, so re-apply to the new cards.
document.body.addEventListener("htmx:afterSwap", applyFilter); document.body.addEventListener("htmx:afterSwap", applyFilter);
document.addEventListener("mangabm:refilter", applyFilter); document.addEventListener("bmgr:refilter", applyFilter);
})(); })();
function setActiveTab(el) { function setActiveTab(el) {
@@ -58,7 +58,7 @@ function setActiveTab(el) {
}); });
// The strip is outside the swapped region, so its visibility is re-decided // The strip is outside the swapped region, so its visibility is re-decided
// here rather than by the server that just answered. // 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 // The chapter-edit form and the archive/finish/remove confirm rows are the
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+2 -2
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 172" role="img" aria-label="mangaBookmark"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 172" role="img" aria-label="BookmarkManager">
<title>mangaBookmark</title> <title>BookmarkManager</title>
<g fill="#100f0e" stroke="#f2ece5" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"> <g fill="#100f0e" stroke="#f2ece5" stroke-width="5" stroke-linejoin="round" stroke-linecap="round">
<path fill="none" d="M28 36H4v114h192V36h-24"></path> <path fill="none" d="M28 36H4v114h192V36h-24"></path>
<path fill="none" d="M28 23H17v127h166V23h-11"></path> <path fill="none" d="M28 23H17v127h166V23h-11"></path>

Before

Width:  |  Height:  |  Size: 973 B

After

Width:  |  Height:  |  Size: 977 B

+70 -1
View File
@@ -97,6 +97,10 @@
--comix: #8a9a7d; --comix: #8a9a7d;
--kagane: #9a8aa5; --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 /* Covers are often missing; the hatch keeps the slot honest instead of
faking artwork. */ faking artwork. */
--hatch: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px); --hatch: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px);
@@ -150,6 +154,8 @@
--demonic: #8a6a55; --demonic: #8a6a55;
--comix: #5f7250; --comix: #5f7250;
--kagane: #6f5f7d; --kagane: #6f5f7d;
--novelfull: #7d6f4f;
--lightnovelworld: #4f7d70;
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px); --hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 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-demonic { color: var(--demonic); }
.site-comix { color: var(--comix); } .site-comix { color: var(--comix); }
.site-kagane { color: var(--kagane); } .site-kagane { color: var(--kagane); }
.site-novelfull { color: var(--novelfull); }
.site-lightnovelworld { color: var(--lightnovelworld); }
.new-chapter { color: var(--ember); } .new-chapter { color: var(--ember); }
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); } .state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
.state svg { width: 10px; height: 10px; } .state svg { width: 10px; height: 10px; }
.is-dim .meta { color: var(--mute-2); } .is-dim .meta { color: var(--mute-2); }
.is-dim .site-asura, .is-dim .site-demonic, .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 ---- */ /* ---- action strip: full-width on a phone, hairline-divided cells ---- */
.actions { .actions {
@@ -714,6 +759,20 @@ button { cursor: pointer; }
color: var(--paper); color: var(--paper);
} }
.login-card h1 em { color: var(--ember); font-style: italic; } .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 form { display: flex; flex-direction: column; gap: 18px; }
.login-card label { .login-card label {
font: 500 10px/1 var(--font-mono); font: 500 10px/1 var(--font-mono);
@@ -754,12 +813,22 @@ button { cursor: pointer; }
color: #fff; 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 ---- */ /* ---- desktop: same measure, actions fold up beside the row ---- */
@media (min-width: 720px) { @media (min-width: 720px) {
:root { --cover-w: 80px; --row-gap: 20px; } :root { --cover-w: 80px; --row-gap: 20px; }
.topbar { padding: 26px 32px 18px; } .topbar { padding: 26px 32px 18px; }
.brand { font-size: 30px; } .brand { font-size: 30px; }
.brand .mark { width: 35px; height: 30px; } .brand .mark { width: 35px; height: 30px; }
.libswitch a { padding: 9px 16px; font-size: 11px; }
.chrome { .chrome {
flex-direction: row; flex-direction: row;
+29 -17
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>mangaBookmark</title> <title>BookmarkManager</title>
<link rel="icon" href="/static/logo.svg" type="image/svg+xml"> <link rel="icon" href="/static/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
@@ -19,7 +19,15 @@
{{template "icons" .}} {{template "icons" .}}
<div class="sheet"> <div class="sheet">
<header class="topbar"> <header class="topbar">
<h1 class="brand">{{template "mark" .}}<span>manga<em>Bookmark</em></span></h1> <h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
{{/* Plain full-page links, not htmx swaps: switching library replaces the
tab row and the chrome, which is a page, not a fragment. */}}
<nav class="libswitch" aria-label="Library">
<a href="/?tab=all" class="{{if eq .Lib "manga"}}active{{end}}"
{{if eq .Lib "manga"}}aria-current="page"{{end}}>Manga</a>
<a href="/?lib=novel&amp;tab=all" class="{{if eq .Lib "novel"}}active{{end}}"
{{if eq .Lib "novel"}}aria-current="page"{{end}}>Novels</a>
</nav>
<form method="post" action="/logout"> <form method="post" action="/logout">
<button type="submit" class="ghost">Log out</button> <button type="submit" class="ghost">Log out</button>
</form> </form>
@@ -38,27 +46,31 @@
navigation, not an ARIA tablist — aria-current carries "which bucket am navigation, not an ARIA tablist — aria-current carries "which bucket am
I in" without owing a tabpanel contract we do not implement. */}} I in" without owing a tabpanel contract we do not implement. */}}
<nav class="tabs" aria-label="Bookmark buckets"> <nav class="tabs" aria-label="Bookmark buckets">
<a href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}" <a href="{{.PageURL "all"}}" class="{{if eq .Tab "all"}}active{{end}}"
{{if eq .Tab "all"}}aria-current="page"{{end}} {{if eq .Tab "all"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML" hx-get="{{.ListURL "all"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a> hx-push-url="{{.PageURL "all"}}" hx-on::after-request="setActiveTab(this)">All</a>
<a href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}" {{/* The one bucket novels do not have: without a poller-fed "what is out
that I have not read", the tab would only ever restate All. */}}
{{if eq .Lib "manga"}}
<a href="{{.PageURL "new"}}" class="tab-new {{if eq .Tab "new"}}active{{end}}"
{{if eq .Tab "new"}}aria-current="page"{{end}} {{if eq .Tab "new"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML" hx-get="{{.ListURL "new"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated hx-push-url="{{.PageURL "new"}}" hx-on::after-request="setActiveTab(this)">Updated
{{template "newcount" .}}</a> {{template "newcount" .}}</a>
<a href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}" {{end}}
<a href="{{.PageURL "fav"}}" class="{{if eq .Tab "fav"}}active{{end}}"
{{if eq .Tab "fav"}}aria-current="page"{{end}} {{if eq .Tab "fav"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML" hx-get="{{.ListURL "fav"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a> hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="setActiveTab(this)">Favourites</a>
<a href="/?tab=archived" class="{{if eq .Tab "archived"}}active{{end}}" <a href="{{.PageURL "archived"}}" class="{{if eq .Tab "archived"}}active{{end}}"
{{if eq .Tab "archived"}}aria-current="page"{{end}} {{if eq .Tab "archived"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=archived" hx-target="#list" hx-swap="innerHTML" hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=archived" hx-on::after-request="setActiveTab(this)">Archived</a> hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
<a href="/?tab=finished" class="{{if eq .Tab "finished"}}active{{end}}" <a href="{{.PageURL "finished"}}" class="{{if eq .Tab "finished"}}active{{end}}"
{{if eq .Tab "finished"}}aria-current="page"{{end}} {{if eq .Tab "finished"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=finished" hx-target="#list" hx-swap="innerHTML" hx-get="{{.ListURL "finished"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=finished" hx-on::after-request="setActiveTab(this)">Finished</a> hx-push-url="{{.PageURL "finished"}}" hx-on::after-request="setActiveTab(this)">Finished</a>
</nav> </nav>
</div> </div>
+5 -2
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>mangaBookmark</title> <title>BookmarkManager</title>
<link rel="icon" href="/static/logo.svg" type="image/svg+xml"> <link rel="icon" href="/static/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
@@ -14,8 +14,11 @@
<main class="login-card"> <main class="login-card">
<div> <div>
<span class="eyebrow">Private library</span> <span class="eyebrow">Private library</span>
<h1 class="brand">{{template "mark" .}}<span>manga<em>Bookmark</em></span></h1> <h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
</div> </div>
<figure class="login-art" aria-hidden="true">
<img src="/static/login-art.png" alt="">
</figure>
<form method="post" action="/login"> <form method="post" action="/login">
<div> <div>
<label for="password">Password</label> <label for="password">Password</label>
+73 -8
View File
@@ -14,8 +14,8 @@ import (
"strings" "strings"
"time" "time"
"mangabm/backend/internal/session" "bookmarkmanager/backend/internal/session"
"mangabm/backend/internal/store" "bookmarkmanager/backend/internal/store"
) )
//go:embed templates //go:embed templates
@@ -40,6 +40,10 @@ type Handler struct {
// listView is what every list-rendering template receives. // listView is what every list-rendering template receives.
type listView struct { 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" Tab string // "all", "fav", or "new"
Recent []store.Bookmark Recent []store.Bookmark
Items []store.Bookmark Items []store.Bookmark
@@ -53,6 +57,23 @@ type listView struct {
OOB bool 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. // loginView is what the login template receives.
type loginView struct { type loginView struct {
Error string Error string
@@ -146,7 +167,7 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "login", loginView{}) h.render(w, http.StatusOK, "login", loginView{})
return 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 { if err != nil {
log.Printf("index: %v", err) log.Printf("index: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -167,6 +188,26 @@ func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []sto
return out 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 // buildListView loads the list once and derives both the tab-filtered items and
// the recent strip from it. // 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 // in All, not in Updated, not in Favourites, and not in the recent strip. An
// archived favourite therefore shows only under Archived: Favourites means // archived favourite therefore shows only under Archived: Favourites means
// "favourites I am currently reading". // "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 all, err := h.store.List() // already ordered updated_at DESC
if err != nil { if err != nil {
return listView{}, err 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 }) reading := filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusReading })
withNew := filterBookmarks(reading, func(b store.Bookmark) bool { return b.HasNewChapter() }) 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] 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) { 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 { if err != nil {
log.Printf("ui list: %v", err) log.Printf("ui list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -241,6 +291,17 @@ func currentTab(r *http.Request) string {
return u.Query().Get("tab") 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 // 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 // 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. // 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. // Archive for Restore.
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) { func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
view.OOB = true 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 { if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
// The card is already written; stale chrome beats a torn response. // The card is already written; stale chrome beats a torn response.
log.Printf("render %s oob: %v", name, err) 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 // refreshChrome rebuilds the chrome for the reader's current tab after a
// mutation and appends it to the response. // mutation and appends it to the response.
func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
log.Printf("ui chrome: %v", err) log.Printf("ui chrome: %v", err)
return return
+18 -12
View File
@@ -12,12 +12,12 @@ import (
"syscall" "syscall"
"time" "time"
"mangabm/backend/internal/api" "bookmarkmanager/backend/internal/api"
"mangabm/backend/internal/httpmw" "bookmarkmanager/backend/internal/httpmw"
"mangabm/backend/internal/latest" "bookmarkmanager/backend/internal/latest"
"mangabm/backend/internal/store" "bookmarkmanager/backend/internal/store"
"mangabm/backend/internal/userscript" "bookmarkmanager/backend/internal/userscript"
"mangabm/backend/internal/web" "bookmarkmanager/backend/internal/web"
) )
// Config holds all runtime settings, sourced from environment variables. // 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. // 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. // Supplied by a bindmount so the script can be edited without a rebuild.
UserscriptPath string 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 configures the background latest-chapter fetcher.
LatestPoll LatestPoll LatestPoll LatestPoll
} }
@@ -138,12 +142,13 @@ func loadLatestPoll() LatestPoll {
func loadConfig() Config { func loadConfig() Config {
c := Config{ c := Config{
Token: os.Getenv("API_TOKEN"), Token: os.Getenv("API_TOKEN"),
DBPath: envOr("DB_PATH", "/data/bookmarks.db"), DBPath: envOr("DB_PATH", "/data/bookmarks.db"),
Port: envOr("PORT", "8080"), Port: envOr("PORT", "8080"),
WebPassword: os.Getenv("WEB_PASSWORD"), WebPassword: os.Getenv("WEB_PASSWORD"),
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"), UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
LatestPoll: loadLatestPoll(), NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
LatestPoll: loadLatestPoll(),
} }
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") { for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
if o = strings.TrimSpace(o); o != "" { 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 // outside the WEB_PASSWORD gate (the script must be installable either
// way). The path segment carries the token instead. // 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}/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} h := &api.Handler{Store: s}
protected := http.NewServeMux() protected := http.NewServeMux()
+74 -1
View File
@@ -11,7 +11,7 @@ import (
"testing" "testing"
"time" "time"
"mangabm/backend/internal/store" "bookmarkmanager/backend/internal/store"
) )
func TestLoadLatestPollDefaults(t *testing.T) { 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) 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)
}
}
+128 -4
View File
@@ -11,9 +11,9 @@ import (
"testing" "testing"
"time" "time"
"mangabm/backend/internal/session" "bookmarkmanager/backend/internal/session"
"mangabm/backend/internal/store" "bookmarkmanager/backend/internal/store"
"mangabm/backend/internal/web" "bookmarkmanager/backend/internal/web"
) )
const testPassword = "hunter2" const testPassword = "hunter2"
@@ -193,7 +193,7 @@ func TestBookmarksAPIStillBearerOnly(t *testing.T) {
func TestStaticAssetsServed(t *testing.T) { func TestStaticAssetsServed(t *testing.T) {
srv, _ := newWebTestServer(t, webConfig()) 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() rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
if rr.Code != http.StatusOK { 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) 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 &amp; 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&amp;tab=fav",
"/?lib=novel&amp;tab=archived",
"/?lib=novel&amp;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&amp;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")
}
}
+15 -15
View File
@@ -1,11 +1,11 @@
# Production override: join an existing Traefik network and let Traefik route # Production override: join an existing Traefik network and let Traefik route
# manga-api.<domain> -> this service with TLS. No host port published. # bookmark-api.<domain> -> this service with TLS. No host port published.
# #
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
# #
# Set in .env: # Set in .env:
# MANGA_API_HOST=manga-api.example.com # your subdomain (required) # BOOKMARK_API_HOST=bookmark-api.example.com # your subdomain (required)
# MANGA_WEB_HOST=manga.example.com # browser UI subdomain, same container (required) # BOOKMARK_WEB_HOST=bookmark.example.com # browser UI subdomain, same container (required)
# PROXY_NETWORK=proxy # Traefik's network name, if not "proxy" # PROXY_NETWORK=proxy # Traefik's network name, if not "proxy"
# TRAEFIK_ENTRYPOINT=websecure # your HTTPS entrypoint name # TRAEFIK_ENTRYPOINT=websecure # your HTTPS entrypoint name
# TRAEFIK_CERTRESOLVER=le # your ACME/cert resolver name # TRAEFIK_CERTRESOLVER=le # your ACME/cert resolver name
@@ -14,7 +14,7 @@
# docker network create proxy # if it doesn't yet # docker network create proxy # if it doesn't yet
services: services:
manga-api: bookmark-api:
# Traffic arrives over the Traefik network, not a published port. # Traffic arrives over the Traefik network, not a published port.
ports: !reset [] ports: !reset []
environment: environment:
@@ -33,19 +33,19 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=${PROXY_NETWORK:-proxy}" - "traefik.docker.network=${PROXY_NETWORK:-proxy}"
- "traefik.http.routers.mangabm.rule=Host(`${MANGA_API_HOST:?set MANGA_API_HOST in .env}`)" - "traefik.http.routers.bmapi.rule=Host(`${BOOKMARK_API_HOST:?set BOOKMARK_API_HOST in .env}`)"
- "traefik.http.routers.mangabm.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" - "traefik.http.routers.bmapi.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.mangabm.tls=true" - "traefik.http.routers.bmapi.tls=true"
- "traefik.http.routers.mangabm.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" - "traefik.http.routers.bmapi.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
- "traefik.http.services.mangabm.loadbalancer.server.port=8080" - "traefik.http.services.bmapi.loadbalancer.server.port=8080"
# Second hostname for the browser UI, same container. Traefik needs the # Second hostname for the browser UI, same container. Traefik needs the
# service named explicitly once more than one router targets it. # service named explicitly once more than one router targets it.
- "traefik.http.routers.mangabm.service=mangabm" - "traefik.http.routers.bmapi.service=bmapi"
- "traefik.http.routers.mangaweb.rule=Host(`${MANGA_WEB_HOST:?set MANGA_WEB_HOST in .env}`)" - "traefik.http.routers.bmweb.rule=Host(`${BOOKMARK_WEB_HOST:?set BOOKMARK_WEB_HOST in .env}`)"
- "traefik.http.routers.mangaweb.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" - "traefik.http.routers.bmweb.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.mangaweb.tls=true" - "traefik.http.routers.bmweb.tls=true"
- "traefik.http.routers.mangaweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" - "traefik.http.routers.bmweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
- "traefik.http.routers.mangaweb.service=mangabm" - "traefik.http.routers.bmweb.service=bmapi"
# headless-shell is untouched here: it keeps its `browser` network membership # headless-shell is untouched here: it keeps its `browser` network membership
# from the base file and must never join `proxy` — that network is shared # from the base file and must never join `proxy` — that network is shared
+10 -7
View File
@@ -1,27 +1,30 @@
# Base stack — works standalone for local smoke testing (`docker compose up`). # 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) # The service binds 127.0.0.1:8080; a host reverse proxy (nginx/Caddy/Traefik)
# terminates TLS for manga-api.<domain> and forwards to it. # terminates TLS for bookmark-api.<domain> and forwards to it.
# #
# If your proxy runs in Docker on its own network, use the prod override which # If your proxy runs in Docker on its own network, use the prod override which
# attaches to that network instead of publishing a port: # attaches to that network instead of publishing a port:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services: services:
manga-api: bookmark-api:
build: ./backend build: ./backend
image: mangabm-backend:latest image: bookmarkmanager-backend:latest
container_name: manga-api container_name: bookmark-api
restart: unless-stopped restart: unless-stopped
environment: environment:
# API_TOKEN is required — compose refuses to start without it. # API_TOKEN is required — compose refuses to start without it.
API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env} 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 DB_PATH: /data/bookmarks.db
PORT: "8080" PORT: "8080"
# Gates the browser UI. Unset means the web routes are not served at all. # Gates the browser UI. Unset means the web routes are not served at all.
WEB_PASSWORD: ${WEB_PASSWORD:-} WEB_PASSWORD: ${WEB_PASSWORD:-}
# Path inside the container; matches the bindmount above. # Path inside the container; matches the bindmount above.
USERSCRIPT_PATH: ${USERSCRIPT_PATH:-/userscript/manga-bookmark.user.js} 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 # Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the kill
# switch; it only takes effect because these are listed here. # switch; it only takes effect because these are listed here.
LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1} LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1}
@@ -63,7 +66,7 @@ services:
# container's lifetime. # container's lifetime.
init: true init: true
# Deliberately no `ports:` — an exposed CDP endpoint is remote code # 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 # Don't pass --remote-debugging-address/--remote-debugging-port here: the
# image's own entrypoint (/headless-shell/run.sh) already starts Chrome on # 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. # 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: networks:
# Not `internal: true`: headless Chrome still needs outbound access to reach # 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. # headless-shell join it), not from cutting egress.
browser: browser:
ipam: ipam:
+18 -15
View File
@@ -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 (`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings
`archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`, `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 `style.css`, `filter.js`). This file records the rules that got implemented so
@@ -10,7 +10,7 @@ Implemented in:
| Surface | Files | | 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 | | Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE |
## 1. The one idea ## 1. The one idea
@@ -41,7 +41,7 @@ Corollaries:
## 2. Tokens ## 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.** `:host`. **Never hardcode a hex outside those two blocks.**
| Token | Dark | Light | Use | | 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 | | `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on |
| `--asura` | `#7d93a5` | `#4f6b80` | site tag | | `--asura` | `#7d93a5` | `#4f6b80` | site tag |
| `--demonic` | `#a98a78` | `#8a6a55` | 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 | | `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
`--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately: `--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 | | Sans | `DM Sans` → system UI | system UI |
The web UI **self-hosts** all three: five latin-subset woff2 files in 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 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 `//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 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): Recurring specs (copy these rather than inventing sizes):
- Brand: `400 26px/1 display` (`30px` ≥720px), inline SVG mark (§4) + `<em>` in - Brand: `400 26px/1 display` (`30px` ≥720px), inline SVG mark (§4) + `<em>` in
ember italic — `manga<em>Bookmark</em>`. ember italic — `Bookmark<em>Manager</em>`.
- Row title: `400 21px/1.2 display` (`22px` ≥720px). - Row title: `400 21px/1.2 display` (`22px` ≥720px).
- Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in - Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in
`--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on `--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 `<svg class="mark">` (`viewBox="0 0 200 172"`), **Brand mark**: an inline `<svg class="mark">` (`viewBox="0 0 200 172"`),
defined once in `chrome.html`'s `mark` template and reused by `app.html` and 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 `login.html` so it takes the page's `--ink`/`currentColor`/`--ember` rather
than shipping as a static asset. The blade at its centre strokes than shipping as a static asset. The blade at its centre strokes `var(--ember)`,
`var(--logo-blade, var(--ember))` — override that custom property, don't so a surface that needs a different blade colour re-points that token rather
duplicate the SVG, if a surface ever needs a different blade colour. Drawn at than duplicating the SVG. Drawn at a 5px stroke on a 200-unit grid; at brand
a 5px stroke on a 200-unit grid; at brand size that thins out, so `.brand .mark size that thins out, so `.brand .mark g` nudges `stroke-width` up to `6.5`
g` nudges `stroke-width` up to `6.5` rather than scaling the artwork down. rather than scaling the artwork down.
**Action key** (`.keyrow`): one permanent line under the tabs naming what **Action key** (`.keyrow`): one permanent line under the tabs naming what
every icon in `.actions` does — Read / Fav / Chapter / Archive / Done / 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 Delete — so the icon strip on a card is never a guess. The key follows the tab,
stacks icon-over-word (`flex-direction: column`) so the word gets the full not the row: Archive becomes Restore under Archived and Finished, and Finished
cell width and can stay in long form; ≥720px it lays out icon-beside-word and drops Done. On a phone each pair stacks icon-over-word
switches the `.short`/`.full` label pair. `.pair.brass` and `.pair.trash` (`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 carry their icon's resting accent so the key itself teaches the colour
vocabulary in §1/§2. vocabulary in §1/§2.
+64
View File
@@ -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>` (slug carries trailing
site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every
redeploy**), chapter `/comics/<slug>/chapter/<n>`. `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>` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title/<slug>/chapter/<n>/<page>` (older `chaptered.php?manga=<id>&chapter=<n>` 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 `/<slug>.html`, chapter
`/<slug>/chapter-<n>[-<title-slug>].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/<slug>/`, chapter
`/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean
title on a series page and `<Title> 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.
+20 -2
View File
@@ -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`) ### 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. 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. 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 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 next navigation, reconnect, or `refresh()`. Entries are markers
(`{key, op, sendStatus, attempts}`), never payloads — body read from (`{key, op, sendStatus, attempts}`), never payloads — body read from
cache at send time, so one entry per key give ordering and coalescing for 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 Encodings (incl. triple-encoded punctuation like `%25252D`) identical
on /manga/ and /title/ pages, so decode-once seriesIds match — verified on /manga/ and /title/ pages, so decode-once seriesIds match — verified
2026-07-28. 2026-07-28.
- **novelfull.com** (novel script): series `/<slug>.html`, chapter
`/<slug>/chapter-<n>[-<title-slug>].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/<slug>/`, chapter
`/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean
title on a series page and `<Title> 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.
+41 -12
View File
@@ -4,8 +4,8 @@
// @version 1.6.0 // @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). // @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
// @author you // @author you
// @downloadURL 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://manga-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://asuracomic.net/*
// @match https://asurascans.com/* // @match https://asurascans.com/*
// @match https://demonicscans.org/* // @match https://demonicscans.org/*
@@ -21,19 +21,38 @@
// ============================================================ // ============================================================
// CONFIG — fill these in before installing. // 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 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. // 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. // Per-device record of when each series was last checked for new chapters.
// Deliberately not synced: each device does its own checking. // 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_THROTTLE_MS = 4 * 60 * 60 * 1000;
const LATEST_CHECK_BATCH = 1; // series fetched per navigation 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 // Site adapters
// //
@@ -503,7 +522,9 @@
} }
function setList(list) { 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(); reindex();
saveCache(state.list); saveCache(state.list);
} }
@@ -528,19 +549,25 @@
return b.status || "reading"; 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 // Retry queue
// //
// A failed write is not rolled back and not lost: the key is parked here and // 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 // 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 // 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 // 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 // archive-then-unarchive collapses to whatever the cache now says, and a
// queued DELETE replaces a queued PUT rather than racing it. // 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 = 200; // ~12 KB; realistically bounded by the bookmark count
const QUEUE_MAX_ATTEMPTS = 10; const QUEUE_MAX_ATTEMPTS = 10;
@@ -812,6 +839,7 @@
const bm = { const bm = {
key: key, key: key,
site: p.site, site: p.site,
kind: LIBRARY,
series_id: p.seriesId, series_id: p.seriesId,
title: p.title || (existing && existing.title) || p.seriesId, title: p.title || (existing && existing.title) || p.seriesId,
series_url: p.seriesUrl || (existing && existing.series_url) || "", series_url: p.seriesUrl || (existing && existing.series_url) || "",
@@ -834,6 +862,7 @@
const bm = Object.assign({}, existing, { const bm = Object.assign({}, existing, {
key: key, key: key,
site: p.site, site: p.site,
kind: LIBRARY,
series_id: p.seriesId, series_id: p.seriesId,
title: existing.title || p.title || p.seriesId, title: existing.title || p.title || p.seriesId,
series_url: existing.series_url || p.seriesUrl || "", series_url: existing.series_url || p.seriesUrl || "",
@@ -1075,7 +1104,7 @@
// FAB placement + dragging (snaps to nearest left/right edge) // 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_MARGIN = 12; // vertical breathing room at the top and bottom
const FAB_EDGE = 0; // horizontal: an edge tab sits flush against the side 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 const ARM_MS = 400; // hold this long on the invisible strip to arm a drag
@@ -1580,7 +1609,7 @@
</g> </g>
</g> </g>
</svg> </svg>
<span>manga<em>Bookmark</em></span> <span>Bookmark<em>Manager</em></span>
</span> </span>
<button id="closeBtn" aria-label="Close">close</button> <button id="closeBtn" aria-label="Close">close</button>
</header> </header>
@@ -1784,7 +1813,7 @@
// Exposes pure logic only — see userscript/test/logic.test.js. // Exposes pure logic only — see userscript/test/logic.test.js.
// ============================================================ // ============================================================
if (typeof window === "undefined" && typeof module === "object" && module.exports) { 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 };
} }
// ============================================================ // ============================================================
File diff suppressed because it is too large Load Diff
+15
View File
@@ -60,6 +60,7 @@ const {
kagane, kagane,
anchorsFromHTML, anchorsFromHTML,
statusOf, statusOf,
kindOf,
} = require("../manga-bookmark.user.js"); } = require("../manga-bookmark.user.js");
// detect() reads only these four properties off location. // 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: "archived" }), "archived");
assert.equal(statusOf({ status: "finished" }), "finished"); 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");
});
+182
View File
@@ -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");
});