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:
@@ -5,3 +5,5 @@
|
|||||||
backend/server
|
backend/server
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
graphify-out/
|
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
@@ -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).
|
|
||||||
Reference in New Issue
Block a user