6af49e6790
Gives every bookmark a lifecycle bucket — `reading`, `archived`, or `finished` — so on-hold series leave the main list while still being polled for new chapters, completed series get a web-only bucket, and the userscript panel gains quick links to the web UI and both manga sites.
Design: `docs/superpowers/specs/2026-07-27-status-buckets-design.md`
## Data model
One additive column through the existing `addedColumns` migration list:
```sql
ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'
```
The `DEFAULT` backfills every pre-existing row as `reading`, so there is no separate migration step. `favorite` is unchanged and orthogonal — a series can be an archived favourite.
Rollback is safe: an old binary against the new database omits `status` from its INSERT (it gets the DEFAULT) and never mentions it in the conflict clause, so buckets survive.
## The write rule
`PUT /bookmarks/{key}` decodes a whole `Bookmark` and `Upsert` writes every column it knows about. `latest_checked_at` escaped this by staying out of `bookmarkColumns` entirely — `status` cannot, because the userscript must be able to archive and restore.
So an empty incoming status means **"no opinion"**, not a value, and resolves on the `VALUES` side of the upsert:
```sql
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')
```
with `DO UPDATE SET status = excluded.status`.
It has to be this way round. `excluded.*` is the row *after* the `VALUES` expressions are evaluated, so applying the default there and then reading `excluded.status` in the conflict clause would see `'reading'` rather than the empty string — and would overwrite an archived row on every progress PUT from a client that knows nothing about the column. One expression, evaluated once, covers insert and update alike. The subquery runs inside the transaction, so it sees the row the statement is about to conflict with.
`TestUpsertEmptyStatusPreservesStored` is the guard on this.
`updated_at` behaviour is unchanged: it moves only when `last_chapter_num` changes, so archiving, finishing, restoring, and favouriting never reorder the list.
## Validation
`PUT /bookmarks/{key}` returns 400 for any status outside `{"", "reading", "archived", "finished"}`, and for `"finished"` specifically. Finishing a series is a web-UI decision, enforced server-side rather than by trusting every client to leave the value alone. The `/ui/*` endpoints have their own session-guarded route and are unaffected.
## Visibility
| Surface | All | Updated | Favourites | Archived | Finished |
|---|---|---|---|---|---|
| Web | reading | reading | reading | archived | finished |
| Userscript | reading | — | reading | archived | not shown |
Archived and finished appear in their own tab and nowhere else — including the web UI's "Continue reading" strip, which is now built from reading-only rows before tab filtering. An archived favourite shows up under Archived only: Favourites means "favourites I am currently reading".
## Backend
- **`store.go`** — `Bookmark.Status`, the column in `schema` / `addedColumns` / `bookmarkColumns` / `scanBookmark` / `Upsert`. `scanBookmark` normalises anything outside the three known buckets to `reading`, so no row can land in no list at all.
- **`store.go`** — `DueForLatestCheck` gains `AND status IS NOT 'finished'`. Archived series keep being polled; that is the whole point of archiving rather than deleting. Finished ones have nothing coming, so polling them only burns fetches and risks a spurious "new chapter" badge. `IS NOT` is null-safe, so a hand-edited NULL still qualifies.
- **`handlers.go`** — status validation on `PUT`, before any write.
- **`web.go`** — `buildListView` filters the new tabs and excludes both buckets from `all` / `new` / `fav` and the recent strip; new `POST /ui/bookmarks/{key}/status`, session-guarded like its siblings, read-modify-writing through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one place.
- **`templates/`** — two more tabs; per-card controls (reading → Archive + Finish, archived → Restore + Finish, finished → Restore); empty-state copy for both new tabs.
- **`static/style.css`** — five tabs no longer divide a phone's width legibly, so the row scrolls sideways instead of squeezing.
## Userscript (1.3.0)
- Third tab **Archived** beside All and Favourites. A missing `status` reads as `reading`, so a list cached by the previous version still renders. `finished` matches no tab and is invisible everywhere.
- Per-item **Archive / Unarchive** button on the existing optimistic path: mutate local state and cache, `apiPut`, adopt the server's returned row.
- Header chip row linking the web UI and both manga sites, each `target="_blank" rel="noopener"`.
- `apiPut` now omits `status` unless the caller opts in — see below.
Still free of every `GM_*` API: plain `fetch`, page `localStorage`, on-page UI only.
## One bug worth calling out
The userscript's other mutations (`updateToCurrentChapter`, `setChapterManual`, `toggleFavorite`, `applyLatestChapterIfChanged`) build their payload with `Object.assign({}, existing, …)`, so they echoed the cached `status` back to the server. `GET /bookmarks` has no status filter — finished rows are in `state.list` and only hidden at render time — which made two failures reachable:
1. Reading a chapter of a series marked finished sent `"status":"finished"`, which the API rejects with 400. Progress never synced, behind a misleading "Offline — saved locally, will retry" toast, permanently.
2. Archiving on desktop and then reading on a phone whose cache predated the archive sent `"status":"reading"` and silently un-archived the series — contradicting the README's "reading an archived series leaves it archived".
Fixed at the single choke point: `apiPut(key, obj, { sendStatus = false })` strips `status` from a copy of the body unless the caller opts in, and only `toggleArchive` opts in. Only an explicit archive/restore has an opinion about the bucket; everything else omits the field so the server's keep-on-empty rule applies. Stripping merely the *invalid* values would not have been enough — a stale cached `"reading"` still clobbers a remote archive.
Also: the userscript's `backgroundRefreshLatest` now skips finished series, matching the server poller, instead of spending batch slots fetching pages for a series that has nothing coming.
## Known limitations, deliberate
Both are marked in-code with `ponytail:` comments naming the ceiling and the upgrade path:
- The poller's `Store.Get` + `Store.Upsert` is not wrapped in a transaction, so a client PUT that commits between the two is lost to the stale re-read. Already documented for read progress in `CLAUDE.md`; it now costs a status change too. Accepted for a single-user deployment.
- A card whose new status no longer matches the active tab stays on screen until the next list load. The alternative is an out-of-band swap or a full list refresh per toggle, and the card visibly showing its new state is enough feedback.
## Testing
`go test ./...` passes; `CGO_ENABLED=0 go build ./...` clean.
- **`store_test.go`** — a fresh row defaults to `reading`; a legacy database gains the column with every row `reading`; an `Upsert` carrying `""` preserves the stored bucket while a value replaces it; a status change does not move `updated_at`; `DueForLatestCheck` returns archived and skips finished; the poller's `Get` → `Upsert` round trip preserves `archived`.
- **`main_test.go`** — `PUT` with `finished` or garbage is 400, `""` / `reading` / `archived` round-trip; a PUT that omits the `status` key entirely (what a pre-1.3.0 userscript sends) preserves an archived bucket *and* applies the chapter progress in the same request.
- **`web_test.go`** — each tab returns only its bucket; the recent strip excludes archived and finished; the status endpoint requires a session, rejects unknown values, and does not move `updated_at`; the card renders the right controls per bucket.
Userscript has no automated harness, so it was checked against a live `https://asurascans.com` page: the chips resolve, Archive moves a series out of All and Favourites into Archived, the state survives a full reload (so it came from the server, not local optimism), Unarchive returns it, a series marked finished in the web UI appears in no tab, and — captured on the wire — the archive PUT carries `"status":"archived"` while a favourite toggle on that same archived series carries no `status` key at all.
Reviewed-on: #4
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
372 lines
12 KiB
Go
372 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"embed"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed templates
|
|
var templateFS embed.FS
|
|
|
|
//go:embed static
|
|
var staticFS embed.FS
|
|
|
|
// recentCount is how many series the "Continue reading" strip shows.
|
|
const recentCount = 5
|
|
|
|
// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
|
// It is a separate handler from bookmarkHandler because the two speak different
|
|
// representations (HTML versus JSON) to different clients under different auth.
|
|
type webHandler struct {
|
|
store *Store
|
|
tmpl *template.Template
|
|
key []byte
|
|
password string
|
|
limiter *loginLimiter
|
|
}
|
|
|
|
// listView is what every list-rendering template receives.
|
|
type listView struct {
|
|
Tab string // "all", "fav", or "new"
|
|
Recent []Bookmark
|
|
Items []Bookmark
|
|
}
|
|
|
|
// Initial is the monogram the templates show in place of a cover when the
|
|
// source site never gave us an og:image. First rune, uppercased; "?" when even
|
|
// the title is missing, so the slot is never empty.
|
|
func (b Bookmark) Initial() string {
|
|
for _, r := range b.Title {
|
|
return strings.ToUpper(string(r))
|
|
}
|
|
return "?"
|
|
}
|
|
|
|
// loginView is what the login template receives.
|
|
type loginView struct {
|
|
Error string
|
|
}
|
|
|
|
// newWebHandler parses every template up front so a broken one kills the
|
|
// process at startup rather than the first request that touches it.
|
|
func newWebHandler(store *Store, cfg Config) (*webHandler, error) {
|
|
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &webHandler{
|
|
store: store,
|
|
tmpl: tmpl,
|
|
key: sessionKey(cfg.Token, cfg.WebPassword),
|
|
password: cfg.WebPassword,
|
|
limiter: newLoginLimiter(),
|
|
}, nil
|
|
}
|
|
|
|
func (h *webHandler) register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /{$}", h.index)
|
|
mux.HandleFunc("POST /login", h.login)
|
|
mux.HandleFunc("POST /logout", h.logout)
|
|
mux.Handle("GET /static/", staticHandler())
|
|
|
|
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList))
|
|
mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite))
|
|
mux.HandleFunc("POST /ui/bookmarks/{key}/status", h.requireSession(h.uiStatus))
|
|
mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
|
|
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
|
|
}
|
|
|
|
// staticHandler serves the embedded assets. An hour, not longer: assets are
|
|
// not fingerprinted, and embed.FS reports a zero ModTime, so http.FileServer
|
|
// emits no Last-Modified or ETag and a client has no way to revalidate a
|
|
// cached copy after a deploy short of waiting out max-age.
|
|
func staticHandler() http.Handler {
|
|
sub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
panic("embed static: " + err.Error())
|
|
}
|
|
files := http.FileServer(http.FS(sub))
|
|
return http.StripPrefix("/static/", http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
files.ServeHTTP(w, r)
|
|
}))
|
|
}
|
|
|
|
// authed reports whether the request carries a valid session cookie.
|
|
func (h *webHandler) authed(r *http.Request) bool {
|
|
c, err := r.Cookie(sessionCookieName)
|
|
return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli())
|
|
}
|
|
|
|
// requireSession guards the fragment endpoints. It answers 401 rather than
|
|
// redirecting, because htmx swaps whatever body it receives into the page and a
|
|
// redirected login page would be spliced into the card list.
|
|
func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !h.authed(r) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
// The status line is already sent, so this can only be logged.
|
|
log.Printf("render %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// index renders the list, or the login page when there is no session. The login
|
|
// page is served at / with status 200 rather than as a redirect to a separate
|
|
// URL: one page, no redirect loop to reason about.
|
|
func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
|
if !h.authed(r) {
|
|
h.render(w, http.StatusOK, "login", loginView{})
|
|
return
|
|
}
|
|
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
|
if err != nil {
|
|
log.Printf("index: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.render(w, http.StatusOK, "app", view)
|
|
}
|
|
|
|
// filterBookmarks returns the subset keep reports true for, preserving order.
|
|
// It always returns a non-nil slice so an empty tab renders its empty state.
|
|
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
|
|
out := []Bookmark{}
|
|
for _, b := range all {
|
|
if keep(b) {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildListView loads the list once and derives both the tab-filtered items and
|
|
// the recent strip from it.
|
|
//
|
|
// Archived and finished series appear in their own tab and nowhere else — not
|
|
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
|
// archived favourite therefore shows only under Archived: Favourites means
|
|
// "favourites I am currently reading".
|
|
func (h *webHandler) buildListView(tab string) (listView, error) {
|
|
all, err := h.store.List() // already ordered updated_at DESC
|
|
if err != nil {
|
|
return listView{}, err
|
|
}
|
|
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
|
|
|
|
// The strip reflects overall reading recency, not the active tab.
|
|
recent := reading
|
|
if len(recent) > recentCount {
|
|
recent = recent[:recentCount]
|
|
}
|
|
|
|
var items []Bookmark
|
|
switch tab {
|
|
case "fav":
|
|
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
|
|
case "new":
|
|
items = filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
|
|
case "archived":
|
|
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
|
|
case "finished":
|
|
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
|
|
default:
|
|
tab = "all"
|
|
items = reading
|
|
}
|
|
return listView{Tab: tab, Recent: recent, Items: items}, nil
|
|
}
|
|
|
|
func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
|
|
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
|
if err != nil {
|
|
log.Printf("ui list: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.render(w, http.StatusOK, "list", view)
|
|
}
|
|
|
|
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
|
ip := clientIP(r)
|
|
if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 {
|
|
secs := int(wait.Seconds()) + 1
|
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
|
h.render(w, http.StatusTooManyRequests, "login", loginView{
|
|
Error: "Too many attempts. Try again in " +
|
|
strconv.Itoa((secs+59)/60) + " min.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
got := r.PostFormValue("password")
|
|
if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 {
|
|
h.limiter.fail(ip, time.Now())
|
|
h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."})
|
|
return
|
|
}
|
|
|
|
h.limiter.reset(ip)
|
|
setSessionCookie(w, r, h.key)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) {
|
|
clearSessionCookie(w, r)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// loadForMutation fetches the row a mutation targets, writing the error
|
|
// response itself when there is nothing to mutate.
|
|
func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) {
|
|
key := r.PathValue("key")
|
|
if key == "" {
|
|
http.Error(w, "missing key", http.StatusBadRequest)
|
|
return Bookmark{}, false
|
|
}
|
|
b, ok, err := h.store.Get(key)
|
|
if err != nil {
|
|
log.Printf("ui get %q: %v", key, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return Bookmark{}, false
|
|
}
|
|
if !ok {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return Bookmark{}, false
|
|
}
|
|
return b, true
|
|
}
|
|
|
|
// saveAndRenderCard upserts and renders the row as stored. Upsert decides
|
|
// whether updated_at moves, so the argument's timestamp is only a candidate and
|
|
// the response must come from the return value.
|
|
//
|
|
// ponytail: the swapped card stays put even when its new status no longer
|
|
// matches the active tab, add an hx-swap-oob list refresh if that reads as a
|
|
// bug rather than as feedback. Archiving from the All tab leaves the card on
|
|
// screen until the next list load. The alternative costs a full list round
|
|
// trip on every toggle, and the card visibly showing its new state is the
|
|
// feedback the user needs.
|
|
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
|
|
stored, err := h.store.Upsert(b)
|
|
if err != nil {
|
|
log.Printf("ui upsert %q: %v", b.Key, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.render(w, http.StatusOK, "card", stored)
|
|
}
|
|
|
|
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
|
// Upsert keeps the stored updated_at and the list does not reorder.
|
|
func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := h.loadForMutation(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
b.Favorite = !b.Favorite
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
h.saveAndRenderCard(w, b)
|
|
}
|
|
|
|
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
|
|
// a series can be marked finished — the JSON API refuses that value, so the
|
|
// userscript cannot set it even by accident.
|
|
//
|
|
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
|
// list does not reorder.
|
|
func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := h.loadForMutation(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
switch s := r.PostFormValue("status"); s {
|
|
case statusReading, statusArchived, statusFinished:
|
|
b.Status = s
|
|
default:
|
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
|
return
|
|
}
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
h.saveAndRenderCard(w, b)
|
|
}
|
|
|
|
// uiChapter forces the read chapter to a value the user typed.
|
|
//
|
|
// Writing the number also clears last_chapter_url: that URL points at the
|
|
// chapter actually read, and once the number is forced elsewhere it would send
|
|
// the reader backwards. ContinueURL then falls back to the series page, which
|
|
// is always right.
|
|
//
|
|
// A submit that does not change the number touches nothing. The form is
|
|
// pre-filled, so a bare tap of Save is an easy accidental submit; it must not
|
|
// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0"
|
|
// to "45") behind a frozen updated_at.
|
|
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
|
b, ok := h.loadForMutation(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
raw := strings.TrimSpace(r.PostFormValue("chapter"))
|
|
num, err := strconv.ParseFloat(raw, 64)
|
|
if err != nil || num < 0 || math.IsNaN(num) || math.IsInf(num, 0) {
|
|
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if num != b.LastChapterNum {
|
|
b.LastChapterURL = ""
|
|
b.LastChapter = raw
|
|
b.LastChapterNum = num
|
|
}
|
|
b.UpdatedAt = time.Now().UnixMilli()
|
|
h.saveAndRenderCard(w, b)
|
|
}
|
|
|
|
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
|
// place of the card — removing it from the page.
|
|
func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
|
key := r.PathValue("key")
|
|
if key == "" {
|
|
http.Error(w, "missing key", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.store.Delete(key); err != nil {
|
|
log.Printf("ui delete %q: %v", key, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|