chore: untrack plans and docs/superpowers dirs

Keep local planning docs out of the repo; add to .gitignore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:56:34 +07:00
parent ebc7a546c5
commit c601ef54d5
6 changed files with 2 additions and 3252 deletions
+2
View File
@@ -5,3 +5,5 @@
backend/server
.playwright-mcp/
graphify-out/
plans/
docs/superpowers/
@@ -1,263 +0,0 @@
# Web UI Design — browser-accessible bookmark list
Date: 2026-07-25
Branch: `feat/web-ui`
Status: approved
## 1. Problem
The bookmark list is reachable only from inside the userscript, which means it
exists only on pages of asurascans.com and demonicscans.org. There is no way to
open the list on its own — from a desktop, from a phone home screen, or when
neither manga site is loaded.
This adds a website, served by the existing Go backend, that renders the same
list with the same actions.
## 2. Scope
In scope:
- Password-gated website showing all bookmarks, ordered by `updated_at DESC`.
- All / Favourites tabs.
- A "Continue reading" strip of the five most recent series.
- Per-series actions: continue reading, toggle favourite, manually override the
read chapter, delete.
- Client-side title search.
Out of scope:
- A chapter-level reading-event log. The list order already answers "what did I
read last". A `reading_events` table is a separate future spec.
- Any change to `GET /bookmarks`, `PUT /bookmarks/{key}`,
`DELETE /bookmarks/{key}`, or to the userscript. Those stay exactly as they
are, so the website cannot regress phone reading.
- Offline support. The userscript keeps its `localStorage` cache; the website is
server-rendered and requires connectivity.
## 3. Architecture
One binary, one container, one SQLite file. The website is added to the running
service rather than deployed alongside it.
```
Bromite userscript ──bearer──> /bookmarks* ─┐
├─> Store ──> SQLite
Browser (phone/desktop) ──cookie──> / , /ui/*┘
```
New files under `backend/`:
| File | Purpose |
| --- | --- |
| `web.go` | Page and HTML-fragment handlers |
| `session.go` | Cookie signing/verification, login rate limit |
| `templates/*.html` | `go:embed`-ed templates |
| `static/*` | `go:embed`-ed `style.css`, `htmx.min.js`, `filter.js` |
Templates and static assets are embedded, so the image stays a single static
binary on distroless and `CGO_ENABLED=0` still holds.
### 3.1 Routes
| Route | Auth | Response |
| --- | --- | --- |
| `GET /` | session | List page; login page when no valid session |
| `POST /login` | none | Sets cookie, `303` to `/` |
| `POST /logout` | session | Clears cookie, `303` to `/` |
| `GET /static/{path...}` | none | Embedded asset, long-lived cache header |
| `GET /ui/list?tab=all\|fav` | session | List fragment |
| `POST /ui/bookmarks/{key}/favorite` | session | Re-rendered card |
| `POST /ui/bookmarks/{key}/chapter` | session | Re-rendered card |
| `DELETE /ui/bookmarks/{key}` | session | `200` with empty body |
`GET /` returns the login page with status `200` rather than redirecting to a
separate login URL. One page, no redirect loop to reason about.
`/ui/*` returns HTML fragments, not JSON, and is authenticated by cookie. It is
kept separate from `/bookmarks*` deliberately: that API is JSON, authenticated
by bearer token, and consumed by the userscript. Sharing one route for two
representations and two auth schemes would couple the website's needs to the
userscript's contract.
Middleware layering is unchanged at the top: `withCORS` stays outermost.
`/bookmarks*` keeps `withAuth` (bearer). `/` and `/ui/*` are wrapped in a new
`withSession`. Web routes are same-origin, so CORS is a no-op for them.
### 3.2 Store change
`Store` gains one method:
```go
func (s *Store) Get(key string) (Bookmark, bool, error)
```
Every UI mutation is read-modify-write: load the row, change the single field,
call the existing `Upsert`, then render the row `Upsert` returns. This reuses
the conditional-`updated_at` rule rather than reimplementing it — favouriting
does not reorder the list, a chapter override does. Rendering the returned row
(not the request payload) is the same contract `PUT /bookmarks/{key}` already
follows.
Not adding `Get` and instead patching columns directly would duplicate the
`updated_at` decision in a second place. That rule has already caused one bug;
it lives in exactly one function.
## 4. Session authentication
### 4.1 Configuration
New environment variable `WEB_PASSWORD`. When it is empty the web routes are not
registered at all and `/` returns `404`. Fail-closed: a deployment that forgets
the variable exposes nothing.
The password is stored in plaintext in `.env`, alongside `API_TOKEN`. This is a
single-user deployment with no user table, and anyone who can read `.env`
already holds the API token, so hashing it protects nothing that is not already
lost. `.env` is gitignored and the repository is private and self-hosted.
### 4.2 Cookie
Name `mangabm_session`. Value:
```
<expiry_unix_ms> "." base64url(HMAC-SHA256(<expiry_unix_ms>, key))
key = SHA256(API_TOKEN || 0x00 || WEB_PASSWORD || "mangabm-web-session-v1")
```
Stateless: no session table, sessions survive restarts, and rotating either
`API_TOKEN` or `WEB_PASSWORD` invalidates every session at once. Both secrets
are bound in so that changing the password actually logs existing browsers out;
the `0x00` separates the two variable-length secrets so no pair of different
inputs can concatenate to the same string.
Attributes: `HttpOnly`, `SameSite=Lax`, `Path=/`, `Max-Age` 60 days so the phone
stays logged in across long gaps. `Secure` is set when `r.TLS != nil` or
`X-Forwarded-Proto: https`, and omitted otherwise so `http://localhost`
development can still log in.
Verification order is fixed: split on `.`, parse the expiry, reject if it is in
the past, and only then `subtle.ConstantTimeCompare` the HMAC. Comparing before
validating the shape leaks structure through error timing.
The password comparison at login is also constant-time.
### 4.3 CSRF
All mutations are `POST` or `DELETE` and carry a `SameSite=Lax` cookie, which a
cross-site form post does not send. No separate CSRF token.
### 4.4 Login rate limit
In-memory, no persistence. Ten failed attempts within a rolling 20-minute window
for one client IP return `429` with a `Retry-After` header. Entries expire on
their own; there is no permanent ban and no unlock step. A successful login
clears that IP's counter.
Client IP is the **rightmost** entry of `X-Forwarded-For`. Traefik appends the
peer address it observed to whatever the client sent, so the leftmost entry is
attacker-controlled and the rightmost is not. `r.RemoteAddr` is unusable here —
behind Traefik it is always the proxy's container address, which would turn a
per-IP limit into a global one.
Known and accepted limitation: behind carrier-grade NAT the limit is shared with
every other subscriber on the same public address, so a stranger exhausting the
budget can lock the owner out for up to 20 minutes. The window self-heals and
ten attempts is generous for a mistyped password, so this is preferred over
removing the limit.
## 5. Interface
Mobile-first. Dark by default, honouring `prefers-color-scheme`. Tap targets at
least 44px. At viewports 900px and wider the card list becomes a 2–3 column
grid.
### 5.1 Login page
A centered card with a single password field (`type="password"`,
`autocomplete="current-password"`) and a submit button. Failed attempts render
an inline error. A rate-limited attempt renders how long to wait.
### 5.2 List page
```
┌──────────────────────────┐
│ mangaBookmark [logout]│
│ [ search… ] │
│ ( All ) ( Favourites ) │
├──────────────────────────┤
│ Continue reading │
│ [card][card][card] → │
├──────────────────────────┤
│ ┌────┬───────────────┐ │
│ │cvr │ Title ASURA│ │
│ │ │ Ch 45 · NEW 47│ │
│ │ │ [Continue]★✎🗑│ │
│ └────┴───────────────┘ │
└──────────────────────────┘
```
- The main list is ordered `updated_at DESC`. That ordering is the reading
history; no separate history view exists.
- "Continue reading" shows the top five of the same ordering in a horizontally
scrolling strip.
- A `NEW` badge appears when `latest_chapter_num` is present and greater than
`last_chapter_num`.
- **Continue** opens `last_chapter_url` in a new tab; it falls back to
`series_url` when no chapter URL is stored.
- The favourite control is an htmx `POST`; the swapped-in card shows the new
state. The list does not reorder.
- The chapter override expands an inline number input on the card. Submitting
forces `last_chapter` and `last_chapter_num` to the entered value, which does
move `updated_at` and therefore does reorder the list.
- Delete asks for confirmation, then htmx removes the card from the DOM.
- Search filters cards by title in the browser with roughly fifteen lines of
JavaScript. No request is made.
- The empty list renders a short message pointing at the userscript.
### 5.3 Tabs
Switching tabs issues `GET /ui/list?tab=…` and swaps the list container,
pushing the URL so the back button works. Favourites is the same list filtered
to `favorite = true`, in the same order.
## 6. Testing
`session_test.go`:
- A signed cookie round-trips and verifies.
- An expired cookie is rejected.
- A cookie with a tampered HMAC is rejected.
- A cookie with a tampered expiry is rejected.
- A correct password logs in; a wrong one does not.
- Ten failures trip the limiter; the eleventh attempt returns `429`.
- A successful login clears the counter.
- The rightmost `X-Forwarded-For` entry is the one keyed on.
`web_test.go`:
- `GET /` without a cookie returns `200` and the login page.
- `/ui/*` without a cookie returns `401`.
- `/ui/list` with a cookie returns the list fragment; `?tab=fav` returns only
favourites.
- Toggling favourite leaves `updated_at` unchanged.
- A chapter override changes `updated_at`.
- Deleting removes the row.
- With `WEB_PASSWORD` empty, `/` returns `404`.
Templates are parsed once at startup so a broken template fails the process
immediately rather than the first request.
## 7. Deployment
- `.env` and `.env.example` gain `WEB_PASSWORD`.
- `docker-compose.prod.yml` gains a second Traefik router label for
`manga.violetcrown.my.id` pointing at the same service on port 8080. Both
routers share one container; no second service, no second certificate
resolver.
- A DNS `A`/`AAAA` record for `manga.violetcrown.my.id`.
- `DEPLOY.md` gains a section covering the DNS record, the new variable, and
generating a password.
`ALLOWED_ORIGINS` is untouched. The website is same-origin and never triggers
CORS; only the userscript's cross-origin calls do.
@@ -1,286 +0,0 @@
# Implementation plan: bookmark reorder, latest-chapter tracking, favorites
## Context
Design already approved and committed at
`plans/2026-07-25-bookmark-list-favorites-design.md` on branch
`feat/bookmark-list-favorites-latest`. It covers three requested userscript
features plus one incidental bug found while verifying feasibility live via
Playwright:
1. Bookmark list reordered so the most-recently-progressed manga is first.
2. Show the latest *available* chapter for a manga, not just the last one
read — including a background same-origin refresh mechanism to get closer
to "live" without server-side polling (Cloudflare blocks that; confirmed
live, and confirmed no JSON API / RSS exists on either site to poll
instead).
3. A favorites mechanism (star toggle + tabs) that doesn't remove a manga
from the normal list.
4. `asuracomic.net` deep links now 301-redirect straight to the
`asurascans.com` homepage (path discarded) — a Cloudflare-edge redirect
confirmed live, with no client-side fix possible. Doc-only correction.
This plan turns that design into concrete code changes against the actual
current backend (Go/SQLite) and userscript, informed by full reads of
`backend/store.go`, `backend/handlers.go`, `backend/store_test.go`,
`backend/main.go`, and the full 782-line
`userscript/manga-bookmark.user.js`.
## Key design decision surfaced during planning
Today `handlers.go`'s `put()` echoes back the client's decoded request
struct as the API response, not what was actually persisted. Once
`updated_at` is sometimes *not* bumped (this whole feature's core mechanic),
echoing the request struct back would return a **wrong** `updated_at` to the
caller on every no-bump write — silently breaking the ordering guarantee the
entire feature depends on, since the userscript's `syncUpsert`/mutation
helpers adopt whatever the server echoes back (`upsertLocal(saved)`) as the
new source of truth. **`Store.Upsert` must therefore return the row as
actually written (read back inside the same transaction), and `handlers.go`
must respond with that**, not the client's payload. This is a correctness
fix required by the design, not a new decision to re-litigate.
Confirmed via the user: background latest-chapter refresh should fire on
Asura's SPA in-app navigation too (`onNavigate()`), not only true browser
page loads (`init()`) — more refresh opportunities on a client-routed site
that rarely does full reloads, still bounded by the same throttle/batch
limits.
## Phase 1 — Backend (`backend/`), TDD
### 1.1 Tests first — `backend/store_test.go`
Add four tests (all go through the existing `newTestServer(t)` /
`httptest` pattern already used in this file, since there are no direct
`Store`-level unit tests in the current style):
- **`TestUpsertConditionalUpdatedAt`** — table-driven: new bookmark (bumps),
unchanged progress (no bump), changed progress (bumps), favorite-only
change (no bump), latest-chapter-only change (no bump). Assert on the
`updated_at` returned by each PUT response.
- **`TestFavoriteRoundTrip`** — PUT `favorite: true`, GET list, assert it
round-trips.
- **`TestLatestChapterNullable`** — PUT without `latest_chapter_num`, assert
JSON response has `"latest_chapter_num":null`; PUT again with a value,
assert it round-trips.
- **`TestOpenStoreMigratesLegacySchema`** — hand-create the *old* (10-column)
schema in a temp DB file, seed one row, then call `OpenStore` on it and
assert the row survives with the new columns defaulting cleanly
(`favorite=false`, `latest_chapter=""`, `latest_chapter_num=nil`). This is
the safety net for the already-deployed production DB.
Run `cd backend && go test ./...` — expect compile failures (red state is
correct/expected before 1.2).
### 1.2 `backend/store.go`
- **`Bookmark` struct**: add `Favorite bool `json:"favorite"``,
`LatestChapter string `json:"latest_chapter"``,
`LatestChapterNum *float64 `json:"latest_chapter_num"`` (nullable — only
this one needs to be a pointer, per the design doc's data-model table).
- **`schema`**: extend `CREATE TABLE IF NOT EXISTS` with
`favorite INTEGER NOT NULL DEFAULT 0`,
`latest_chapter TEXT NOT NULL DEFAULT ''`, `latest_chapter_num REAL`
(covers fresh installs only).
- **Idempotent migration for the already-deployed DB**: add a
`migrateColumns(db)` helper using `PRAGMA table_info(bookmarks)` to check
each new column's existence before running its `ALTER TABLE ... ADD
COLUMN` (SQLite has no `ADD COLUMN IF NOT EXISTS`). Call it in
`OpenStore` right after the existing `schema` exec succeeds, same
error-wrapping style as today.
- **Shared `scanBookmark` helper**: centralizes converting the `favorite`
`INTEGER` (0/1) to `bool` and the nullable `latest_chapter_num` `REAL` to
`*float64` via `sql.NullFloat64`, used by both `List()` and `Upsert()`'s
read-back.
- **`List()`**: extend the `SELECT` to the new columns, scan via
`scanBookmark`.
- **`Upsert(b Bookmark) (Bookmark, error)`** — signature changes to return
the stored row. Implementation: wrap in `db.Begin()`/`tx.Commit()`
(explicit "read exactly what I just wrote" guarantee rather than relying
on `SetMaxOpenConns(1)` staying 1 forever). The `INSERT ... ON CONFLICT
DO UPDATE SET` gets a `CASE` expression for `updated_at`:
```sql
updated_at = CASE
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
THEN excluded.updated_at
ELSE bookmarks.updated_at
END
```
This is valid SQLite upsert syntax (bare column = pre-update row value,
`excluded.col` = proposed new row) and naturally handles "new row" for
free — `ON CONFLICT DO UPDATE` only fires on the update path, so a
genuinely new row goes through the plain `INSERT ... VALUES` and always
gets the fresh `updated_at`. After the exec, `SELECT` the row back inside
the same transaction and return it via `scanBookmark`.
### 1.3 `backend/handlers.go`
In `put()`: keep `b.UpdatedAt = time.Now().UnixMilli()` as a *candidate*
value (update its comment — it's no longer unconditionally authoritative),
then:
```go
stored, err := h.store.Upsert(b)
...
writeJSON(w, http.StatusOK, stored)
```
No other changes — `Favorite`/`LatestChapter`/`LatestChapterNum` already
flow through untouched from the decoded body, which is correct (they're
fully client-set synced fields). `list()`/`delete()` unchanged.
### 1.4 Green + build
```bash
cd backend && go test ./... # all pass, including pre-existing TestBookmarkRoundTrip unmodified
cd backend && CGO_ENABLED=0 go build # static binary still builds
```
## Phase 2 — Userscript (`userscript/manga-bookmark.user.js`)
No JS test harness in this repo — verification is manual (Phase 4).
1. **Config constants** (near `CACHE_KEY`, ~line 24): `LASTCHECKED_KEY =
"mangabm:lastchecked"`, `LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000`
(4h), `LATEST_CHECK_BATCH = 1`.
2. **Shared anchor extraction** so the exact same per-site chapter-matching
rule runs against both the live DOM and raw fetched HTML text (no HTML
parser available for the fetch path): `anchorsFromDocument(doc)` (via
`querySelectorAll("a[href]")`) and `anchorsFromHTML(html)` (regex-based
`<a href="...">...</a>` extraction). Add near `meta()` (~line 34).
3. **Per-adapter `latestChapterFromAnchors(anchors)`** added to both `asura`
and `demonic` adapter objects, implementing the regex rules from the
design doc (Asura: href matches `/chapter/([\d.]+)$/` AND text matches
`/Chapter\s+[\d.]+/i`, excluding the "First Chapter" quick-jump button;
Demonic: all `chaptered.php?manga=\d+&chapter=([\d.]+)` matches, take
max — no order assumption). Plus a `computeLatestChapter(site, anchors)`
dispatcher near `keyOf()`.
4. **`mangabm:lastchecked` local helpers**: `loadLastChecked()` /
`saveLastChecked(map)`, parallel to existing `loadCache`/`saveCache`
(~line 154), storing `{ [bookmarkKey]: timestampMs }`. Client-local only,
never synced.
5. **`applyLatestChapterIfChanged(existing, latest)`** (~near `syncUpsert`,
line 295): if `latest.num` differs from the bookmark's stored
`latest_chapter_num`, optimistically update local cache + render, then
`apiPut` with `updated_at: Date.now()` as a *candidate* — the backend
(Phase 1) decides whether to actually apply it, and the client adopts
whatever comes back via `upsertLocal(saved)`, same pattern the rest of
the file already uses. No client-side "don't reorder" logic needed
beyond that — the server is the single source of truth for it. Silent
on failure (no toast), per the design doc.
6. **Live-page capture**: `maybeCaptureLatestOnSeriesPage()` — on a
`type: "series"` page for an already-bookmarked series, scan the live
DOM via `anchorsFromDocument` + `computeLatestChapter`, then
`applyLatestChapterIfChanged`. Hooked into `onNavigate()` (~line 633),
after the existing `maybeAutoUpdate()` call.
7. **Background opportunistic refresh**: `backgroundRefreshLatest()` — get
`currentSite()` (which adapter matches `window.location`), pick
same-site bookmarks not checked within `LATEST_CHECK_THROTTLE_MS`
(oldest-checked-first), fetch+parse at most `LATEST_CHECK_BATCH` of them
via `fetch(bm.series_url).then(r => r.text())` → `anchorsFromHTML` →
`computeLatestChapter` → `applyLatestChapterIfChanged`. Mark each
attempted bookmark's `lastchecked` timestamp regardless of success/failure
(advances the throttle window either way, avoiding hammering a
consistently-failing fetch). Silent on failure.
**Hook into both `init()` and `onNavigate()`** (per user's confirmed
preference — more refresh opportunities on Asura's SPA navigation, same
throttle/batch caps prevent request bursts either way).
8. **`toggleFavorite(key)`** (~near `setChapterManual`, line 293): flips
`favorite`, optimistic update, `apiPut` with `Date.now()` candidate
timestamp (again, backend decides), toast on success/failure (consistent
with other explicit user-initiated actions like bookmark/remove).
9. **Tabs**: new module state `let activeTab = "all";` (~near `panelOpen`,
line 374; not persisted, defaults to "all"). Wire click handlers in
`buildUI()` for new `#tabAll`/`#tabFav` elements. In `render()` (~line
568), toggle each tab's `.active` class and filter which array feeds the
list-building loop (`activeTab === "favorites" ? state.list.filter(b =>
b.favorite) : state.list`) — `state.list` itself is never mutated/filtered,
so a favorited manga always still appears in "All".
10. **`renderItem(b)`** (~line 579): add a star toggle button (☆/★,
`onclick: () => toggleFavorite(b.key)`) alongside the existing
Continue/Edit/Remove buttons, and change the subtitle line to
`"Read: " + last_chapter + " · Latest: " + latest_chapter` when
`latest_chapter_num` is known and strictly greater than
`last_chapter_num` (avoids showing "Latest: Chapter 12" next to "Read:
Chapter 12" when they're numerically equal); otherwise keep today's
`"<last_chapter> · <site>"` text.
11. **`TEMPLATE`** (~line 679): insert a tabs bar
(`<div id="tabs"><button id="tabAll" class="tab active">All</button>
<button id="tabFav" class="tab">★ Favorites</button></div>`) between
`#context` and `#list`.
12. **`CSS`** (~near `.ctx-sub`/`.btn.danger`): add `.tab`/`.tab.active` and
`.btn.star`/`.btn.star.active` rules following the existing dark-theme
`.btn` modifier convention (`.btn.primary`, `.btn.small`, `.btn.danger`).
13. **Fix the misleading redirect comment** (line 41, in the `asura`
adapter object): replace "asuracomic.net currently 301s to
asurascans.com; match both." with an accurate note that the redirect
now discards the path (goes straight to the asurascans.com root),
happens at the Cloudflare edge before any JS runs, so no client-side
fix is possible, and the user should navigate via asurascans.com links
directly. No change to the `matches()` regex itself.
14. **`@version`**: bump `1.1.0` → `1.2.0`.
## Phase 3 — Documentation
- **`CLAUDE.md`**: update the `PUT /bookmarks/{key}` endpoint description
(currently "upsert, server sets updated_at") to describe the new
conditional rule, referencing
`plans/2026-07-25-bookmark-list-favorites-design.md` §4.
- **`README.md`**: update the matching endpoint-table row, and correct the
"Adapter reference" section's Asura row to note `asuracomic.net` deep
links currently 301 to the asurascans.com root (broken/path discarded) —
use `asurascans.com` links directly. While touching this, also fix
`CLAUDE.md`'s intro line ("asuracomic.net (formerly asurascans.com)"),
which has the relationship backwards and is inconsistent with README's
own phrasing ("asurascans.com (a.k.a. asuracomic.net)") — bundle this
small adjacent correction in since it's directly related to the same
finding.
## Verification
**Backend (automated):**
```bash
cd backend && go test ./...
cd backend && CGO_ENABLED=0 go build
```
**Backend (manual smoke test, extends the existing curl convention in
CLAUDE.md/README.md):** PUT a new bookmark, then PUT again changing only
`favorite`, then only `latest_chapter*`, then a real `last_chapter_num`
advance — confirm via `jq .updated_at` that only the first and last calls
change `updated_at`.
**Userscript (manual — no JS test harness exists in this repo, matching
existing project convention):**
- List reorders only on a genuine progress advance, not on plain re-visit,
favorite toggle, or latest-chapter capture.
- Latest-chapter capture fires when visiting a bookmarked series page on
both sites and displays the "Read: X · Latest: Y" subtitle correctly.
- Background refresh: check `localStorage['mangabm:lastchecked']` in
devtools to confirm throttling behavior; confirm via the Network tab that
it never fires a cross-site request (only same-origin as the currently
loaded site).
- Favorite toggle persists across a panel close/reopen and a `refresh()`
round-trip through the backend; favorited manga still shows in "All".
- Fastest iteration path: desktop Tampermonkey/Violentmonkey first (script
stays `GM_*`-free), then confirm on Bromite per existing project
convention.
## Critical files
- `backend/store.go`
- `backend/handlers.go`
- `backend/store_test.go`
- `userscript/manga-bookmark.user.js`
- `CLAUDE.md`
- `README.md`
@@ -1,231 +0,0 @@
# Bookmark list ordering, latest-chapter display, and favorites
Date: 2026-07-25
Status: Approved by user, pending implementation plan
## Context
Three requested additions to the Bromite userscript's bookmark panel
(`userscript/manga-bookmark.user.js`) plus one incidental bug found while
verifying feasibility live:
1. Bookmark list should show the most-recently-read manga first.
2. Show not just the last chapter *read*, but the latest chapter *available*
for that manga, if feasible.
3. A favorites mechanism: a second list/tab showing only favorited manga,
without removing favorited manga from the normal list.
## 1. Reorder by latest read (already implemented)
`reindex()` in `manga-bookmark.user.js` has sorted `state.list` by
`(b.updated_at || 0)` descending since the very first commit
(`f58d113`). `updated_at` is set whenever progress is recorded
(`bookmarkCurrent`, `updateToCurrentChapter`, `setChapterManual`). No code
change needed here — confirmed by decision: only an actual progress advance
should reorder the list (not simply opening/re-reading an old chapter).
The only risk to this existing behavior is introduced by features 2 and 3
below, since both add new fields synced through the same PUT endpoint that
currently (per existing CLAUDE.md) has the server set `updated_at`
unconditionally on every upsert. See section 4.
## 2. Latest available chapter
### Feasibility (verified live via Playwright, 2026-07-25)
- **Chapter reader pages only expose immediate neighbors.** On
`asurascans.com/comics/dungeon-odyssey-f886a8af/chapter/160`, the DOM
contains only chapters 159–161 (prev/next nav) — not the full list.
- **Series pages list every chapter, newest first by default.** On
`asurascans.com/comics/dungeon-odyssey-f886a8af`, the series page's chapter
list panel contains one `<a href=".../chapter/N">Chapter N ...</a>` per
chapter (verified: 162 links for a 162-chapter series, first is Chapter
162). This is therefore the only reliable place to learn the true latest
chapter number.
- Demonic's series page (`demonicscans.org/manga/Dungeon-Odyssey`) similarly
lists every chapter via `chaptered.php?manga=<id>&chapter=<n>` links,
interleaved with a "first chapter" quick-jump link. Taking `max()` of all
parsed chapter numbers (rather than assuming list order) is used for
robustness on this site.
**Conclusion: capturing latest-available-chapter data requires fetching a
series page's HTML** (no reader-page source, no JSON API — see below). The
backend cannot do this itself (Cloudflare blocks server-side fetch, per
existing CLAUDE.md constraint), so it must happen from the userscript,
running in the user's real browser session. Section "Background opportunistic
refresh" below extends this beyond "only when you open that exact series
page."
### No stable API exists (checked live, 2026-07-25)
- The series page is server-rendered HTML (Next.js SSR) — network capture
during a series-page load shows no `_next/data` JSON call and no XHR/fetch
for chapter data (only unrelated `api.asurascans.com` calls for
announcements/promotions banners). Chapter data is embedded directly in
the HTML, not fetched separately.
- No RSS/feed exists: `asurascans.com/feed`, `/rss`, `/rss.xml` all 404.
- Conclusion: HTML scraping (DOM when live on the page, raw-text regex when
background-fetched — see below) is the only available data source on
either site. There is nothing more "API-like" to poll instead.
### Behavior
- New adapter capability: on `detect()` returning `type: "series"` for an
already-bookmarked series (`state.byKey[key]` exists), scan the page for
chapter links per the site-specific pattern below and compute the max
chapter number + its display label.
- Asura: `a[href*="/chapter/"]` where the href matches
`/chapter/([\d.]+)$/` and the link text matches `/Chapter\s+[\d.]+/`
(excludes the unrelated "First Chapter" quick-jump button, which lacks
that text pattern).
- Demonic: all `a[href]` matching
`/chaptered\.php\?manga=\d+&chapter=([\d.]+)/`; take the max parsed
chapter number across all matches (list order is not assumed reliable).
- If the computed max differs from the bookmark's stored
`latest_chapter_num`, silently update `latest_chapter` (label) and
`latest_chapter_num` via the API — but this update must **not** change
`updated_at` / list order (see section 4).
- Display: each list item's subtitle line becomes e.g.
`"Read: Chapter 12 · Latest: Chapter 15"` when latest is known and differs
from last-read; otherwise unchanged (`"Chapter 12 · asura"` as today).
Plain inline text, no separate badge/count UI.
### Background opportunistic refresh (closer to "live")
Live-page capture above only refreshes a series when the user happens to
open that exact series page. To reduce that gap without server-side
polling (still blocked by Cloudflare — verified: server-side fetch is a
datacenter request with no browser session, this is unchanged and not
being revisited), the userscript also does same-origin background checks
using the user's own real browser session:
- **Same-origin only.** `fetch()` issued from a page on `asurascans.com`
can only safely reach other `asurascans.com` paths (no CORS trouble,
looks like a normal authenticated browser request). It cannot reach
`demonicscans.org` or vice versa. So visiting any page on a site
opportunistically refreshes only that site's bookmarks — confirmed live
that same-origin `fetch()` from an already-loaded page succeeds cleanly
(tested against `asurascans.com/sitemap.xml` from a `comics/*` page).
- **Trigger:** on every page load, after `init()`/`refresh()`, run
`backgroundRefreshLatest()`. It picks bookmarks belonging to the
*current* site that haven't been checked within a throttle window
(`LATEST_CHECK_THROTTLE_MS`, proposed 4 hours), oldest-checked-first, and
checks at most `LATEST_CHECK_BATCH` of them (proposed 1) per page load —
so a normal reading session gradually keeps bookmarks fresh without ever
bursting requests.
- **Freshness tracking is local-only**, not synced: a small
`mangabm:lastchecked` localStorage map of `{ [key]: timestampMs }`. It's
device-local by nature (each device does its own background checks) and
keeping it out of the synced `Bookmark` record avoids polluting the
cross-device schema with a per-device value.
- **Fetch + parse:** `fetch(bookmark.series_url)` → `res.text()` → apply
the *same* regex rule already defined above (Asura: chapter-link href +
"Chapter N" text; Demonic: max of all `chaptered.php?...chapter=`
matches) against the raw HTML string instead of the live DOM. No
additional parsing logic — this reuses the exact same rule, just fed
fetched text instead of `document`.
- **Update path:** identical to live-page capture — PUT the bookmark with
new `latest_chapter`/`latest_chapter_num` if changed, no `updated_at`
bump (section 4).
- **Failure handling:** a failed/blocked background fetch is silently
skipped (no toast, no retry loop) — next eligible page load tries again
naturally once the throttle window passes.
- **What this buys:** instead of "only fresh if you opened that exact
series page," bookmarks on a site you're actively reading converge to
"checked within the last ~4 hours," which is meaningfully closer to live
given frequent reading — without needing server-side scraping that
Cloudflare would block anyway. It is still not push/instant; nothing
can notify the moment a new chapter is posted without the site itself
offering that (it doesn't — no RSS/webhooks, confirmed above).
## 3. Favorites
- New field `favorite: bool` on the bookmark record, synced through the
existing PUT endpoint (chosen over local-only storage so favorites persist
across devices/reinstalls, consistent with how the rest of the data
syncs).
- Each list item gets a star toggle (☆ / ★) that flips `favorite` and PUTs
the updated bookmark. Toggling **must not** change `updated_at` / list
order (see section 4).
- The panel gains two tabs above the bookmark list: **All** and
**★ Favorites**. Both apply the same sort (section 1). Switching tabs is
local UI state (not persisted) defaulting to "All". A favorited manga
continues to appear in "All" — tabs only filter which array is rendered,
favoriting never removes the bookmark from `state.list`.
## 4. Backend change required: conditional `updated_at`
Current CLAUDE.md / `store.go` behavior: `PUT /bookmarks/{key}` always sets
`updated_at` server-side on every upsert. Once latest-chapter-capture and
favorite-toggle both PUT through that same endpoint, this would reorder the
list on every series-page visit or star click — contradicting the
"reorder only on progress advance" decision from section 1.
**Change:** `Store.Upsert` sets `updated_at = now()` only when:
- the bookmark is new (no existing row for that key), or
- `last_chapter_num` in the incoming payload differs from the currently
stored value.
Otherwise the existing stored `updated_at` is preserved, even though other
fields (`favorite`, `latest_chapter`, `latest_chapter_num`, cover, title,
etc.) are still updated. This centralizes "what counts as a progress
advance" as a single authoritative rule in the backend, applied consistently
regardless of which device/browser performed the write.
This is a deliberate deviation from the current CLAUDE.md wording ("server
sets `updated_at`" unconditionally) and needs the doc updated to match.
## 5. Asura redirect bug (bundled into this work)
Verified live: `https://asuracomic.net/comics/dungeon-odyssey-f886a8af`
returns an HTTP **301** with `Location: https://asurascans.com/` (root, no
path) — a Cloudflare-edge redirect that discards the path *before any JS on
asuracomic.net executes*. This contradicts the existing code comment
("asuracomic.net currently 301s to asurascans.com", implying path
preservation) and means any deep link on `asuracomic.net` currently lands
the user on the asurascans.com homepage with page type `"other"` —
bookmark/progress detection silently does nothing.
There is no client-side fix: the userscript's `@match` for
`asuracomic.net/*` never gets a chance to run for these URLs, since the
redirect happens at the edge before the browser has a document to inject
into. **Fix is documentation-only**: correct the misleading comment in
`asura.matches()`/adapter notes and README to state that `asuracomic.net`
deep links are currently broken, and the user should navigate via
`asurascans.com` links directly. No behavior change to ship.
## Data model summary
`Bookmark` (backend `store.go`) gains:
| field | type | notes |
|---|---|---|
| `favorite` | `bool` | default `false` |
| `latest_chapter` | `string` | display label, e.g. `"Chapter 162"`; empty if never captured |
| `latest_chapter_num` | `*float64` | nullable; null if never captured |
SQLite migration: additive `ALTER TABLE bookmarks ADD COLUMN ...` for each,
guarded against "duplicate column" errors so it's safe to run against the
already-deployed database.
Userscript-local, not synced: `mangabm:lastchecked` localStorage key, a
`{ [bookmarkKey]: timestampMs }` map used only to throttle background
refresh (see section 2).
## Testing
- Go: table-driven tests for `Store.Upsert`'s conditional `updated_at` logic
(new bookmark, unchanged progress, changed progress, favorite-only change,
latest-chapter-only change).
- Userscript: manual on-device verification (per existing project
convention — no JS test harness in this repo). Verify:
- list reorders only when a chapter is actually advanced, not on
plain re-visit or favorite toggle.
- latest-chapter capture fires on series-page visits and displays
correctly for both sites.
- background refresh: check one stale same-site bookmark per page load,
skips bookmarks checked within the throttle window, updates silently
without reordering the list, and doesn't fire cross-site.
- favorite toggle persists across a panel close/reopen and a
`refresh()` (i.e. round-trips through the backend correctly).
- favorited manga still appears in "All" tab.
File diff suppressed because it is too large Load Diff
-142
View File
@@ -1,142 +0,0 @@
# Manga Bookmark — Userscript + Self-Hosted Sync Backend
## Context
The user reads manga on **asurascans.com** (now serves from `asuracomic.net`) and **demonicscans.org**, on a **mobile browser (Bromite)**. They want to bookmark a series and auto-record the latest chapter they've read, with a UI reachable while those sites are open on the phone. Progress must sync across devices, so it lives in a backend on the user's own server.
### Hard constraints (drive the whole design)
Bromite uses Chromium's native userscript engine — **not** Tampermonkey. Per Bromite's wiki / Chromium docs:
- **No `GM_setValue` / `GM_getValue`** → persistence must use page `localStorage`.
- **No `GM_registerMenuCommand`** → UI must be injected on-page (floating button + panel).
- **`GM_xmlhttpRequest` is same-origin only** → cross-origin calls use plain `fetch()`, which works only against a **CORS-enabled** backend.
- Userscripts run in an **isolated world** → the manga site's JS cannot read our embedded API token (safe to embed).
- `asurascans.com` and `demonicscans.org` are **separate origins** with **separate `localStorage`** → the only way to unify bookmarks across both sites is a **shared remote store**. Cloud sync is therefore required, not a nice-to-have.
- The manga sites are `https://`, so the backend **must be HTTPS** (mixed-content block otherwise). User already runs a reverse proxy + domain, so a subdomain (e.g. `manga-api.<domain>`) fronts the service.
### Decisions locked with user
- Backend: **self-hosted, Go** (resource-friendly), Docker Compose, behind existing reverse proxy (TLS handled there).
- Scope: **track read progress only** — no "new chapters available" detection (YAGNI for v1).
- Record last-read: **auto on chapter open + manual override** in the panel.
- UI: **floating button + slide-in panel**.
---
## Architecture
```
Bromite (mobile)
userscript (isolated world, per-site adapters)
localStorage cache <--> fetch() over HTTPS
|
reverse proxy (TLS, CORS origin)
|
Go service (net/http) -> SQLite file (volume)
```
Two deliverables in this repo:
```
mangaBookmark/
backend/
main.go # server bootstrap, config from env, router
handlers.go # GET/PUT/DELETE /bookmarks, /healthz
store.go # SQLite open + queries (modernc.org/sqlite, CGO_ENABLED=0)
middleware.go # bearer-token auth + CORS/preflight
store_test.go # handler + store tests (httptest + temp sqlite)
go.mod
Dockerfile # multi-stage: golang:alpine build -> scratch/distroless
docker-compose.yml # service + named volume for the sqlite file
userscript/
manga-bookmark.user.js
README.md # deploy steps + Bromite install steps + config
```
---
## Backend (Go)
**Stack:** stdlib `net/http` (no framework needed for 3 routes) + `modernc.org/sqlite` (pure Go → static binary, `scratch` image). Rust/axum + `rusqlite` is a drop-in alternative if preferred later.
**Data model** — one table, `key` unique across both sites:
```sql
CREATE TABLE IF NOT EXISTS bookmarks (
key TEXT PRIMARY KEY, -- "<site>:<series_id>"
site TEXT NOT NULL, -- "asura" | "demonic"
series_id TEXT NOT NULL,
title TEXT,
series_url TEXT,
cover TEXT,
last_chapter TEXT, -- label as shown, e.g. "Chapter 123"
last_chapter_num REAL, -- parsed for max() comparison
last_chapter_url TEXT,
updated_at INTEGER NOT NULL -- unix ms
);
```
**Endpoints** (JSON):
- `GET /bookmarks` → array of all bookmarks (single-user store).
- `PUT /bookmarks/{key}` → upsert one series (body = bookmark object). Server sets `updated_at`.
- `DELETE /bookmarks/{key}` → remove one.
- `GET /healthz` → `200 ok` (no auth).
**Middleware:**
- **Auth:** require `Authorization: Bearer <API_TOKEN>` (env) on `/bookmarks*`; 401 otherwise. Constant-time compare.
- **CORS:** reflect `Origin` when it's in `ALLOWED_ORIGINS` (env, comma list: `https://asuracomic.net,https://asurascans.com,https://demonicscans.org`). Allow methods `GET,PUT,DELETE,OPTIONS`, headers `Authorization,Content-Type`. Answer preflight `OPTIONS` with `204`.
**Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS`, `DB_PATH` (default `/data/bookmarks.db`), `PORT` (default `8080`).
**Dockerfile:** multi-stage — `golang:1.23-alpine` build with `CGO_ENABLED=0 go build`, final stage `gcr.io/distroless/static` (or `scratch`) copying the binary; `/data` volume for the SQLite file. (See `multi-stage-dockerfile` skill.)
**docker-compose.yml:** one service, named volume mounted at `/data`, env vars, `restart: unless-stopped`. Attach to the existing reverse-proxy network (or expose a local port the proxy targets) — the proxy terminates TLS and routes `manga-api.<domain>` → service `:8080`. (See `docker-compose-orchestration` skill.)
---
## Userscript (`manga-bookmark.user.js`)
Single Bromite-compatible file. **No `GM_*` calls anywhere** (so it also runs in desktop Tampermonkey/Violentmonkey for faster iteration). Wrapped in an IIFE.
**Metadata header:** `@match https://asuracomic.net/*`, `https://asurascans.com/*`, `https://demonicscans.org/*`; `@run-at document-idle`; `@name`, `@version`.
**Config block (top of file, user fills in):**
```js
const API_BASE = "https://manga-api.<domain>";
const API_TOKEN = "<paste token>";
```
**Modules inside the IIFE:**
1. **Site adapters** — one per host, each exposing `detect(location, document)` → `{ type: 'series'|'chapter'|'other', site, seriesId, title, cover, seriesUrl, chapterLabel, chapterNum, chapterUrl }`.
- Identify page **type + IDs from URL regex** (most stable); pull `title`/`cover` from **`og:title` / `og:image` meta tags** (present and stable on both sites, avoids brittle CSS classes).
- Asura: series `…/series/<slug>-<id>`, chapter `…/series/<slug>-<id>/chapter/<n>` → `seriesId = <slug>-<id>`, `chapterNum = n`.
- Demonic: series `…/manga/<slug>`, chapter reader `…/title/<id>/<chapterId>` (exact reader path to be confirmed against live DOM).
- **URL patterns + selectors get verified against live pages during implementation** (Cloudflare blocks server-side fetch; confirm via Playwright MCP or on-device devtools before finalizing).
2. **API client** — `apiGet()`, `apiPut(key, obj)`, `apiDelete(key)` via `fetch` with the bearer header. `localStorage` key `mangabm:cache` holds the last-known bookmark list for instant render + offline fallback.
3. **Progress logic** — on a chapter page of a **bookmarked** series, auto-upsert `last_chapter` when `chapterNum >= stored last_chapter_num` (so re-reading old chapters doesn't regress progress; unparseable → set current). Manual override in the panel forces any value. Bookmarking a new series is available from both series and chapter pages.
4. **Floating UI** — rendered inside a **Shadow DOM** root (isolates from site CSS; important on mobile). Fixed circular button bottom-right (respects safe-area insets, high `z-index`); tap toggles a slide-in panel:
- Header: context of current page — "+ Bookmark this" if unbookmarked, else current progress + "Update to this chapter".
- List: bookmarks sorted by `updated_at` desc — title, last chapter, **Continue** link (→ `last_chapter_url` or `series_url`), edit-chapter input, remove.
- Toasts for sync success/failure.
5. **SPA navigation** — Asura is a Next.js client-routed app (no full reload on chapter change). Patch `history.pushState`/`replaceState` + listen for `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic (classic reloads) works via the initial `document-idle` run.
**Sync strategy:** last-write-wins (single user). On load: `apiGet()` → render → cache. On mutation: optimistic cache+UI update, then `PUT`/`DELETE`; on failure show a toast, keep local, retry on next load.
---
## Verification
**Backend**
- `go test ./...` — auth (401 without/with bad token), CORS preflight headers + origin reflection, upsert→get→delete round-trip against a temp SQLite file.
- Local smoke: `docker compose up`, then `curl` `GET/PUT/DELETE` with `Authorization` header; confirm `OPTIONS` preflight returns the CORS headers.
- Deployed: hit `https://manga-api.<domain>/healthz`; confirm valid TLS (no mixed-content) and preflight from a real site origin.
**Userscript**
- Confirm adapter URL regex + `og:` extraction on live Asura + Demonic pages (Playwright MCP or on-device devtools) before finalizing selectors.
- Desktop dry-run in Tampermonkey/Violentmonkey (same file, no GM APIs): bookmark a series, open a chapter, verify panel updates and `curl GET /bookmarks` on the server reflects it.
- On Bromite: install per README, repeat the flow on both sites; verify auto-update on chapter open, manual override, Continue link, and cross-site unified list (bookmark on Asura shows when panel opened on Demonic).
**Open items to confirm during build:** exact Demonic reader URL path + chapter-number source; Asura's live series/chapter path (post-`asuracomic.net` migration); Bromite's current userscript-install steps (documented in README, verified on-device).