rebrand: MangaBM → BookmarkManager, add novel library support #15
+7
-6
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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). |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
module mangabm/backend
|
module bookmarkmanager/backend
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||||
|
|||||||
@@ -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&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,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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+12
-6
@@ -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
|
||||||
}
|
}
|
||||||
@@ -143,6 +147,7 @@ func loadConfig() Config {
|
|||||||
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"),
|
||||||
|
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
|
||||||
LatestPoll: loadLatestPoll(),
|
LatestPoll: loadLatestPoll(),
|
||||||
}
|
}
|
||||||
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
||||||
@@ -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
@@ -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
@@ -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 & inside an attribute value, so that — not
|
||||||
|
// the raw URL — is what lands in the body. htmx and the browser both decode
|
||||||
|
// it on read, so only the assertion has to know.
|
||||||
|
for _, want := range []string{
|
||||||
|
"/?lib=novel&tab=fav",
|
||||||
|
"/?lib=novel&tab=archived",
|
||||||
|
"/?lib=novel&tab=finished",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("novel page missing tab link %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `class="libswitch"`) {
|
||||||
|
t.Fatal("novel page missing the library switch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, st := newWebTestServer(t, cfg)
|
||||||
|
seedLibraries(t, st)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/?tab=all", nil))
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "/?tab=new") {
|
||||||
|
t.Fatal("manga page must keep the Updated tab")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "lib=novel&tab=new") {
|
||||||
|
t.Fatal("the Updated tab must never be emitted for the novel library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tab=new is not offered for novels, so a hand-typed one must land on All
|
||||||
|
// rather than an empty page.
|
||||||
|
func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
||||||
|
cfg := webConfig()
|
||||||
|
srv, st := newWebTestServer(t, cfg)
|
||||||
|
seedLibraries(t, st)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?lib=novel&tab=new", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "A Will Eternal") {
|
||||||
|
t.Fatal("novel tab=new should render the novel All list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+15
-15
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user