From 051c429b24082c7ff31ad7a4e18104ec15708b60 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 12:46:27 +0700 Subject: [PATCH 01/10] feat(store): add status column with keep-on-empty upsert rule --- backend/store.go | 42 +++++++++++++-- backend/store_test.go | 116 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) diff --git a/backend/store.go b/backend/store.go index d75916c..a6b7b61 100644 --- a/backend/store.go +++ b/backend/store.go @@ -27,6 +27,10 @@ type Bookmark struct { LatestChapter string `json:"latest_chapter"` LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert + // Status is the lifecycle bucket: reading, archived, or finished. + // Archived series stay polled for new chapters; finished ones do not. + // Empty on the way in means "no opinion" — see Upsert. + Status string `json:"status"` } // HasNewChapter reports whether the site has published past the read point. @@ -45,6 +49,13 @@ func (b Bookmark) ContinueURL() string { return b.SeriesURL } +// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal. +const ( + statusReading = "reading" + statusArchived = "archived" + statusFinished = "finished" +) + const schema = ` CREATE TABLE IF NOT EXISTS bookmarks ( key TEXT PRIMARY KEY, @@ -60,6 +71,7 @@ CREATE TABLE IF NOT EXISTS bookmarks ( latest_chapter TEXT NOT NULL DEFAULT '', latest_chapter_num REAL, latest_checked_at INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'reading', updated_at INTEGER NOT NULL );` @@ -73,11 +85,14 @@ var addedColumns = []struct{ name, ddl string }{ // sorts first so a new bookmark is picked up on the next tick with no // special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked. {"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`}, + // Lifecycle bucket. The DEFAULT backfills every pre-existing row as + // 'reading', so there is no separate migration step. + {"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`}, } const bookmarkColumns = `key, site, series_id, title, series_url, cover, last_chapter, last_chapter_num, last_chapter_url, - favorite, latest_chapter, latest_chapter_num, updated_at` + favorite, latest_chapter, latest_chapter_num, updated_at, status` // Store is the SQLite-backed bookmark store. type Store struct { @@ -156,13 +171,14 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) { b Bookmark title, seriesURL, cover sql.NullString lastChapter, lastChapterURL, latestChapter sql.NullString + status sql.NullString lastChapterNum, latestChapterNum sql.NullFloat64 favorite sql.NullInt64 ) if err := scan( &b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover, &lastChapter, &lastChapterNum, &lastChapterURL, - &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, + &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, ); err != nil { return Bookmark{}, err } @@ -177,6 +193,12 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) { if latestChapterNum.Valid { b.LatestChapterNum = &latestChapterNum.Float64 } + // A NULL or empty bucket would leave the row in no list at all, so it + // reads as the default rather than being passed through. + b.Status = status.String + if b.Status == "" { + b.Status = statusReading + } return b, nil } @@ -242,9 +264,19 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) { // IS NOT is SQLite's null-safe comparison. Within DO UPDATE, a bare column // is the stored row and excluded.* is the incoming one; a brand-new key // never reaches this clause, so it keeps the fresh timestamp from VALUES. + // + // The status column resolves on the VALUES side, not in the conflict + // clause: excluded.* is the row *after* these expressions are evaluated, + // so a default applied there would look identical to a real 'reading' and + // would overwrite an archived row on every PUT from a client that knows + // nothing about the column. Resolved once here, an empty incoming status + // means "keep what is stored", and only a brand-new row falls through to + // the literal default. The subquery runs inside this transaction, so it + // sees the row this statement is about to conflict with. if _, err := tx.Exec(` INSERT INTO bookmarks (`+bookmarkColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')) ON CONFLICT(key) DO UPDATE SET site=excluded.site, series_id=excluded.series_id, title=excluded.title, series_url=excluded.series_url, cover=excluded.cover, @@ -253,6 +285,7 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) { favorite=excluded.favorite, latest_chapter=excluded.latest_chapter, latest_chapter_num=excluded.latest_chapter_num, + status=excluded.status, updated_at=CASE WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num THEN excluded.updated_at @@ -260,7 +293,8 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) { END`, b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, b.LastChapter, b.LastChapterNum, b.LastChapterURL, - b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt); err != nil { + b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt, + b.Status, b.Key); err != nil { return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err) } diff --git a/backend/store_test.go b/backend/store_test.go index 6102db9..3cc3792 100644 --- a/backend/store_test.go +++ b/backend/store_test.go @@ -722,3 +722,119 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) { t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got) } } + +func TestUpsertDefaultsStatusToReading(t *testing.T) { + store := newTestStore(t) + stored, err := store.Upsert(Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + UpdatedAt: time.Now().UnixMilli(), + }) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.Status != statusReading { + t.Fatalf("Status = %q, want %q", stored.Status, statusReading) + } +} + +// An empty status means "no opinion" — a userscript build that predates the +// column must not reset the bucket on every progress PUT. +func TestUpsertEmptyStatusPreservesStored(t *testing.T) { + store := newTestStore(t) + base := Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Status: statusArchived, UpdatedAt: time.Now().UnixMilli(), + } + if _, err := store.Upsert(base); err != nil { + t.Fatalf("seed: %v", err) + } + + base.Status = "" + base.LastChapterNum = 12 + stored, err := store.Upsert(base) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.Status != statusArchived { + t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived) + } +} + +func TestUpsertReplacesStatusWhenGiven(t *testing.T) { + store := newTestStore(t) + base := Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Status: statusArchived, UpdatedAt: time.Now().UnixMilli(), + } + if _, err := store.Upsert(base); err != nil { + t.Fatalf("seed: %v", err) + } + + base.Status = statusReading + stored, err := store.Upsert(base) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.Status != statusReading { + t.Fatalf("Status = %q, want %q", stored.Status, statusReading) + } +} + +// A status change is not reading progress, so the list must not reorder. +func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) { + store := newTestStore(t) + base := Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + LastChapter: "45", LastChapterNum: 45, + UpdatedAt: time.Now().UnixMilli(), + } + first, err := store.Upsert(base) + if err != nil { + t.Fatalf("seed: %v", err) + } + + base.Status = statusArchived + base.UpdatedAt = first.UpdatedAt + 60_000 + stored, err := store.Upsert(base) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.UpdatedAt != first.UpdatedAt { + t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt) + } +} + +// A database written before the column existed must gain it, with every +// pre-existing row landing in the reading bucket. +func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open: %v", err) + } + if _, err := db.Exec(` + CREATE TABLE bookmarks ( + key TEXT PRIMARY KEY, site TEXT NOT NULL, series_id TEXT NOT NULL, + title TEXT, series_url TEXT, cover TEXT, + last_chapter TEXT, last_chapter_num REAL, last_chapter_url TEXT, + updated_at INTEGER NOT NULL); + INSERT INTO bookmarks (key, site, series_id, updated_at) + VALUES ('asura:old', 'asura', 'old', 1)`); err != nil { + t.Fatalf("seed legacy: %v", err) + } + db.Close() + + store, err := OpenStore(path) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + defer store.Close() + + b, ok, err := store.Get("asura:old") + if err != nil || !ok { + t.Fatalf("Get: ok=%v err=%v", ok, err) + } + if b.Status != statusReading { + t.Fatalf("Status = %q, want %q", b.Status, statusReading) + } +} -- 2.52.0 From d320016cebe07472b6b843051e2893d891f33941 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 12:49:52 +0700 Subject: [PATCH 02/10] feat(poller): skip finished series, keep polling archived ones --- backend/store.go | 5 +++++ backend/store_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/backend/store.go b/backend/store.go index a6b7b61..39e40d9 100644 --- a/backend/store.go +++ b/backend/store.go @@ -327,10 +327,15 @@ func (s *Store) Delete(key string) error { // // Bookmarks with no series_url are skipped — there is nothing to fetch, which // is the same filter the userscript applies at L452. +// +// Finished series are excluded: nothing more is coming, so fetching them only +// burns requests. Archived ones are deliberately still polled — knowing what a +// shelved series is up to is the whole reason for archiving instead of deleting. func (s *Store) DueForLatestCheck(cutoffMs int64, limit int) ([]Bookmark, error) { rows, err := s.db.Query(`SELECT `+bookmarkColumns+` FROM bookmarks WHERE series_url IS NOT NULL AND series_url <> '' + AND status IS NOT 'finished' AND latest_checked_at <= ? ORDER BY latest_checked_at ASC LIMIT ?`, cutoffMs, limit) diff --git a/backend/store_test.go b/backend/store_test.go index 3cc3792..c1eddac 100644 --- a/backend/store_test.go +++ b/backend/store_test.go @@ -838,3 +838,37 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) { t.Fatalf("Status = %q, want %q", b.Status, statusReading) } } + +// Archiving is the reason to keep polling — the point is to come back to a +// series that has moved on. A finished series has nothing left to publish. +func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) { + store := newTestStore(t) + for _, tc := range []struct{ key, status string }{ + {"asura:reading", statusReading}, + {"asura:archived", statusArchived}, + {"asura:finished", statusFinished}, + } { + if _, err := store.Upsert(Bookmark{ + Key: tc.key, Site: "asura", SeriesID: tc.key, + SeriesURL: "https://asurascans.com/comics/" + tc.key, + Status: tc.status, UpdatedAt: time.Now().UnixMilli(), + }); err != nil { + t.Fatalf("seed %s: %v", tc.key, err) + } + } + + due, err := store.DueForLatestCheck(time.Now().UnixMilli(), 10) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + got := map[string]bool{} + for _, b := range due { + got[b.Key] = true + } + if !got["asura:reading"] || !got["asura:archived"] { + t.Fatalf("due = %v, want reading and archived present", got) + } + if got["asura:finished"] { + t.Fatal("due included a finished series") + } +} -- 2.52.0 From f783b72a81d05e3ecdc93cc4259eec5572963a41 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 12:53:02 +0700 Subject: [PATCH 03/10] feat(api): validate status on PUT, refuse finished from clients --- backend/handlers.go | 15 ++++++++++++++ backend/main_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/backend/handlers.go b/backend/handlers.go index c427bf1..025d670 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -60,6 +60,21 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) { } } } + + // An empty status is "no opinion" and Upsert keeps the stored bucket. + // Finishing a series is a web-UI decision, so the JSON API refuses it + // rather than trusting every client to leave it alone. + switch b.Status { + case "", statusReading, statusArchived: + case statusFinished: + http.Error(w, "status "+statusFinished+" can only be set from the web UI", + http.StatusBadRequest) + return + default: + http.Error(w, "invalid status", http.StatusBadRequest) + return + } + // Candidate timestamp, not a decision: Upsert keeps the stored one unless // reading progress actually moved. Any client value is ignored. b.UpdatedAt = time.Now().UnixMilli() diff --git a/backend/main_test.go b/backend/main_test.go index 90145bb..d199eb7 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -1,6 +1,11 @@ package main import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" "time" ) @@ -113,3 +118,45 @@ func TestLoadLatestPollClampsAndFallsBack(t *testing.T) { }) } } + +func TestPutStatusValidation(t *testing.T) { + cases := []struct { + name string + status string + want int + }{ + {"empty is no opinion", "", http.StatusOK}, + {"reading", "reading", http.StatusOK}, + {"archived", "archived", http.StatusOK}, + {"finished is web-only", "finished", http.StatusBadRequest}, + {"garbage", "dropped", http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := newTestServer(t) + body := fmt.Sprintf(`{"title":"Solo","status":%q}`, tc.status) + req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", + strings.NewReader(body))) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != tc.want { + t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String()) + } + if tc.want != http.StatusOK { + return + } + var got Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + want := tc.status + if want == "" { + want = "reading" + } + if got.Status != want { + t.Fatalf("stored status = %q, want %q", got.Status, want) + } + }) + } +} -- 2.52.0 From 5aae6786686886cfd104525145b39d95e5451cf4 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 12:56:12 +0700 Subject: [PATCH 04/10] feat(web): filter tabs and recent strip by status bucket Co-Authored-By: Claude Opus 5 --- backend/web.go | 45 +++++++++++++++--------- backend/web_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/backend/web.go b/backend/web.go index 7d88a4e..952ff7d 100644 --- a/backend/web.go +++ b/backend/web.go @@ -145,38 +145,51 @@ func (h *webHandler) index(w http.ResponseWriter, r *http.Request) { 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. The strip always reflects overall recency, not the -// active tab, so it is built before filtering. +// 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 }) - recent := all + // The strip reflects overall reading recency, not the active tab. + recent := reading if len(recent) > recentCount { recent = recent[:recentCount] } - items := all + var items []Bookmark switch tab { case "fav": - items = []Bookmark{} - for _, b := range all { - if b.Favorite { - items = append(items, b) - } - } + items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite }) case "new": - items = []Bookmark{} - for _, b := range all { - if b.HasNewChapter() { - items = append(items, b) - } - } + 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 } diff --git a/backend/web_test.go b/backend/web_test.go index b744162..b107630 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -513,3 +513,89 @@ func TestUIListNewTab(t *testing.T) { t.Fatal("new tab included a series already caught up") } } + +// seedStatusRows puts one series in each bucket, the archived one also +// favourited and with a new chapter out, so a leak into any reading-bucket tab +// shows up as a failure rather than passing by accident. +func seedStatusRows(t *testing.T, store *Store) { + t.Helper() + // floatPtr already exists in store_test.go — same package, reuse it. + rows := []Bookmark{ + {Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne", + Status: statusReading, LastChapterNum: 10, Favorite: true, + LatestChapter: "11", LatestChapterNum: floatPtr(11)}, + {Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne", + Status: statusArchived, LastChapterNum: 5, Favorite: true, + LatestChapter: "99", LatestChapterNum: floatPtr(99)}, + {Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne", + Status: statusFinished, LastChapterNum: 200, Favorite: true}, + } + for _, b := range rows { + b.UpdatedAt = time.Now().UnixMilli() + if _, err := store.Upsert(b); err != nil { + t.Fatalf("seed %s: %v", b.Key, err) + } + } +} + +func TestTabsShowOnlyTheirBucket(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + cases := []struct { + tab string + want, dontWant []string + }{ + {"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}}, + {"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}}, + {"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}}, + {"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}}, + {"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}}, + } + for _, tc := range cases { + t.Run(tc.tab, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil) + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, w := range tc.want { + if !strings.Contains(body, w) { + t.Fatalf("tab %s missing %s", tc.tab, w) + } + } + for _, d := range tc.dontWant { + if strings.Contains(body, d) { + t.Fatalf("tab %s leaked %s", tc.tab, d) + } + } + }) + } +} + +func TestRecentStripExcludesArchivedAndFinished(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + req := httptest.NewRequest(http.MethodGet, "/?tab=archived", nil) + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + strip := rr.Body.String() + if i := strings.Index(strip, `id="list"`); i >= 0 { + strip = strip[:i] // everything above the list is the recent section + } + if strings.Contains(strip, "ArchivedOne") || strings.Contains(strip, "FinishedOne") { + t.Fatal("recent strip included an archived or finished series") + } + if !strings.Contains(strip, "ReadingOne") { + t.Fatal("recent strip dropped the reading series") + } +} -- 2.52.0 From 8579f5a0edabd2da7e44e3ee61378631070f07a5 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 13:00:21 +0700 Subject: [PATCH 05/10] feat(web): add status endpoint for archive, finish, and restore Co-Authored-By: Claude Opus 5 --- backend/web.go | 27 ++++++++++++++++ backend/web_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/backend/web.go b/backend/web.go index 952ff7d..7a5487d 100644 --- a/backend/web.go +++ b/backend/web.go @@ -79,6 +79,7 @@ func (h *webHandler) register(mux *http.ServeMux) { 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)) } @@ -283,6 +284,32 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) { 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 diff --git a/backend/web_test.go b/backend/web_test.go index b107630..315c07f 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -599,3 +599,79 @@ func TestRecentStripExcludesArchivedAndFinished(t *testing.T) { t.Fatal("recent strip dropped the reading series") } } + +func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"status": {status}} + req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/"+key+"/status", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + return rr +} + +func TestUIStatusSetsBucket(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + for _, want := range []string{statusArchived, statusFinished, statusReading} { + if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK { + t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String()) + } + b, ok, err := store.Get("asura:reading") + if err != nil || !ok { + t.Fatalf("Get: ok=%v err=%v", ok, err) + } + if b.Status != want { + t.Fatalf("stored status = %q, want %q", b.Status, want) + } + } +} + +func TestUIStatusRejectsUnknownValue(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rr.Code) + } + b, _, _ := store.Get("asura:reading") + if b.Status != statusReading { + t.Fatalf("stored status = %q, want it untouched", b.Status) + } +} + +func TestUIStatusRequiresSession(t *testing.T) { + srv, store := newWebTestServer(t, webConfig()) + seedStatusRows(t, store) + + req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status", + strings.NewReader("status=archived")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rr.Code) + } +} + +func TestUIStatusDoesNotReorderList(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + before, _, _ := store.Get("asura:reading") + time.Sleep(2 * time.Millisecond) + if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + after, _, _ := store.Get("asura:reading") + if after.UpdatedAt != before.UpdatedAt { + t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt) + } +} -- 2.52.0 From 4d396a3582b009c03b9c903549b1e9312f8707d1 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 13:06:11 +0700 Subject: [PATCH 06/10] feat(web): archived and finished tabs with per-card status controls --- backend/static/style.css | 13 ++++++-- backend/templates/app.html | 6 ++++ backend/templates/card.html | 42 ++++++++++++++++++++++++++ backend/templates/list.html | 4 +++ backend/web_test.go | 59 +++++++++++++++++++++++++++++++++++-- 5 files changed, 120 insertions(+), 4 deletions(-) diff --git a/backend/static/style.css b/backend/static/style.css index c385c49..f7d2f93 100644 --- a/backend/static/style.css +++ b/backend/static/style.css @@ -131,10 +131,19 @@ button, .primary, [role="tab"] { .search:focus-visible { outline: 2px solid var(--accent); } -.tabs { display: flex; gap: 8px; margin-bottom: 16px; } +/* Five buttons no longer divide a phone's width legibly, so the row scrolls + sideways instead of squeezing. The scrollbar is hidden: on mobile there is + none anyway, and on desktop it would sit under the tab labels. */ +.tabs { + display: flex; gap: 8px; margin-bottom: 16px; + overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; +} +.tabs::-webkit-scrollbar { display: none; } .tabs [role="tab"] { - flex: 1; + flex: 1 0 auto; + padding-inline: 14px; + white-space: nowrap; display: grid; place-items: center; background: var(--surface); diff --git a/backend/templates/app.html b/backend/templates/app.html index dcbaed4..1726355 100644 --- a/backend/templates/app.html +++ b/backend/templates/app.html @@ -31,6 +31,12 @@ Favourites + Archived + Finished {{if .Recent}} diff --git a/backend/templates/card.html b/backend/templates/card.html index 8278ca9..63293b8 100644 --- a/backend/templates/card.html +++ b/backend/templates/card.html @@ -38,6 +38,48 @@ + {{if eq .Status "finished"}} + + {{else}} + {{if eq .Status "archived"}} + + {{else}} + + {{end}} + + {{end}} +
+
@@ -960,6 +1004,13 @@ padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px; } #closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; } + #nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; } + .chip { + background: #2a2a33; color: #c4b5fd; text-decoration: none; + padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600; + white-space: nowrap; + } + .chip:active { opacity: .8; } #context { padding: 12px 16px; border-bottom: 1px solid #33333d; display: flex; flex-direction: column; gap: 8px; -- 2.52.0 From 896792d5044c7244c67c88b9510d8e1876657236 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 13:25:25 +0700 Subject: [PATCH 08/10] docs: record status buckets and userscript nav chips --- CLAUDE.md | 15 ++++++++++++++- README.md | 9 +++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index ee7b87c..dfc7fcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,16 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache) value now differs, moving `updated_at` and reordering the list. This is a known, accepted limitation for a single-user deployment, not a bug to fix. - **`updated_at` drives list order, so it moves only on real reading progress:** the server applies its timestamp when the row is new or `last_chapter_num` changes, and otherwise keeps the stored value — favouriting a series or recording a newly published chapter must not reorder the list. `PUT` therefore returns the row **as stored**, and clients must adopt that response rather than their own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4. +- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` | + `finished`, orthogonal to `favorite`. Archived and finished appear only in + their own tab — not in All, Updated, Favourites, or the recent strip. The + poller keeps checking archived series and skips finished ones. `finished` is + settable only from the web UI; `PUT /bookmarks/{key}` rejects it with 400. + **An empty incoming status means "keep the stored one"** — resolved on the + `VALUES` side of `Store.Upsert`, not in the conflict clause, because + `excluded.*` is the post-evaluation row and a default applied there would + wipe the bucket on every PUT from a client that predates the column. See + `docs/superpowers/specs/2026-07-27-status-buckets-design.md`. - **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH` (default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD` (gates the browser UI; unset disables it), @@ -68,7 +78,10 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache) 1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes. 2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback. 3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. -4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS (critical on mobile). +4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS + (critical on mobile). Three tabs (All / Favourites / Archived) and a row of + link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG + block next to `API_BASE`. 5. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices). ### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting) diff --git a/README.md b/README.md index bb884be..b0aa729 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,15 @@ desktop for faster testing — install the same file unchanged. - **Favourites**: the ☆ on any row toggles it; the **★ Favourites** tab narrows the list. Favourited series still appear under **All**. The flag syncs, so it follows you across devices; the chosen tab does not persist. +- **Archive**: the **Archive** button on any row parks a series — it drops out + of **All** and **★ Favourites** and moves to the **Archived** tab. The server + keeps checking it for new chapters, so it is worth coming back to. Archiving + does not touch read progress, and reading an archived series leaves it + archived. +- **Finished**: series you have completed live in a **Finished** tab in the web + UI only. It is set there and nowhere else — the API rejects the value — and + finished series are hidden from every userscript tab and are no longer polled + for new chapters. - Bookmarks made on Asura appear when the panel is opened on Demonic, and vice versa — the backend is the shared store. -- 2.52.0 From 7a16abd6a322b58a202ce65dabaa4e0e12190574 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 13:35:46 +0700 Subject: [PATCH 09/10] fix: omit status on non-archive writes, skip finished in refresh and scan --- backend/main_test.go | 36 +++++++++++++++++++++++++++++++ backend/store.go | 7 +++--- backend/store_test.go | 33 ++++++++++++++++++++++++++++ userscript/manga-bookmark.user.js | 13 ++++++++--- 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/backend/main_test.go b/backend/main_test.go index d199eb7..dd87c8e 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -160,3 +160,39 @@ func TestPutStatusValidation(t *testing.T) { }) } } + +// A PUT that omits the status field entirely (what a userscript build +// predating the column sends) is the actual preserve path — the "" case +// above only exercises the fresh-INSERT default and never touches an +// existing bucket. This must both keep the archived bucket and still apply +// the chapter progress carried in the same request. +func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) { + srv := newTestServer(t) + + seed := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", + strings.NewReader(`{"title":"Solo","status":"archived"}`)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, auth(seed)) + if rr.Code != http.StatusOK { + t.Fatalf("seed status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) + } + + req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", + strings.NewReader(`{"title":"Solo","last_chapter":"12","last_chapter_num":12}`)) + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(req)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) + } + + var got Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Status != "archived" { + t.Fatalf("stored status = %q, want %q", got.Status, "archived") + } + if got.LastChapterNum != 12 { + t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum) + } +} diff --git a/backend/store.go b/backend/store.go index 39e40d9..e3df5d2 100644 --- a/backend/store.go +++ b/backend/store.go @@ -193,10 +193,11 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) { if latestChapterNum.Valid { b.LatestChapterNum = &latestChapterNum.Float64 } - // A NULL or empty bucket would leave the row in no list at all, so it - // reads as the default rather than being passed through. + // A NULL, empty, or unrecognised bucket (e.g. a hand-edited row) would + // leave the row in no list at all, so anything outside the three known + // buckets reads as the default rather than being passed through. b.Status = status.String - if b.Status == "" { + if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished { b.Status = statusReading } return b, nil diff --git a/backend/store_test.go b/backend/store_test.go index c1eddac..619a5ff 100644 --- a/backend/store_test.go +++ b/backend/store_test.go @@ -760,6 +760,39 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) { } } +// Mirrors the poller's read-modify-write in latest.go: Get the current row, +// mutate only the latest-chapter fields, and Upsert the whole struct back. +// cur.Status comes from Get (never empty — see scanBookmark), so it must +// round-trip through Upsert unchanged rather than being reset. +func TestLatestPollRoundTripPreservesArchived(t *testing.T) { + store := newTestStore(t) + base := Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Status: statusArchived, UpdatedAt: time.Now().UnixMilli(), + } + if _, err := store.Upsert(base); err != nil { + t.Fatalf("seed: %v", err) + } + + cur, found, err := store.Get(base.Key) + if err != nil || !found { + t.Fatalf("Get: found=%v err=%v", found, err) + } + + num := 7.0 + cur.LatestChapter = "7" + cur.LatestChapterNum = &num + cur.UpdatedAt = time.Now().UnixMilli() + + stored, err := store.Upsert(cur) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.Status != statusArchived { + t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived) + } +} + func TestUpsertReplacesStatusWhenGiven(t *testing.T) { store := newTestStore(t) base := Bookmark{ diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 791799b..ee2d1d8 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -271,11 +271,17 @@ return res.json(); } - async function apiPut(key, obj) { + // Only an explicit archive/restore has an opinion about the bucket. Every + // other write omits `status`, so the server keeps the stored one — otherwise + // a cached value would resend "finished" (which the API rejects with 400) or + // silently un-archive a series archived on another device. + async function apiPut(key, obj, { sendStatus = false } = {}) { + const body = Object.assign({}, obj); + if (!sendStatus) delete body.status; const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { method: "PUT", headers: authHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify(obj), + body: JSON.stringify(body), }); if (!res.ok) throw new Error("PUT /bookmarks " + res.status); return res.json(); @@ -433,7 +439,7 @@ upsertLocal(bm); render(); try { - const saved = await apiPut(bm.key, bm); + const saved = await apiPut(bm.key, bm, { sendStatus: true }); upsertLocal(saved); // adopt the stored row: the server owns updated_at render(); toast(next === "archived" ? "Archived" : "Back in your list"); @@ -489,6 +495,7 @@ const now = Date.now(); const due = state.list .filter((b) => b.site === site && b.series_url) + .filter((b) => statusOf(b) !== "finished") .filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS) .sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0)) .slice(0, LATEST_CHECK_BATCH); -- 2.52.0 From aaf5c3990a43d5041a9ac5ec05fd3e8a2d6abfd2 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Mon, 27 Jul 2026 13:55:45 +0700 Subject: [PATCH 10/10] chore: ignore the go build artifact, mark two accepted limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go build ./...` in backend/ emits `backend/backend` (the module is `mangabm/backend`), but .gitignore only listed `backend/server` — the name the Dockerfile uses for its container-internal build. The real artifact was untracked and unignored, and came close to being committed twice. Keeps the `server` entry, since a local `go build -o server` still matches the Dockerfile's naming. Also records two deliberate limitations as `ponytail:` comments so they are greppable rather than living only in prose: - latest.go: the poller's Store.Get + Store.Upsert is not wrapped in a transaction, so a client PUT committing between the two is lost to the stale re-read. Already documented in CLAUDE.md; the marker names the upgrade path (wrap in a tx) and the trigger (more than one user). Note this now costs a status change, not just read progress. - web.go: a swapped card stays on screen when its new status no longer matches the active tab. The alternative is a full list round trip per toggle; the card showing its new state is the feedback that matters. Comments only — no logic change. Tests pass, CGO_ENABLED=0 builds. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + backend/latest.go | 7 +++++++ backend/web.go | 7 +++++++ 3 files changed, 15 insertions(+) diff --git a/.gitignore b/.gitignore index 5c85376..1762a52 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.db-shm *.db-wal backend/server +backend/backend .playwright-mcp/ graphify-out/ plans/ diff --git a/backend/latest.go b/backend/latest.go index 4d08d0d..40a36aa 100644 --- a/backend/latest.go +++ b/backend/latest.go @@ -148,6 +148,13 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) { // Re-read: the row may have been updated or deleted while the fetch was in // flight, and writing b back wholesale would undo that. + // + // ponytail: non-transactional read-modify-write, wrap Get+Upsert in a tx if + // this ever runs for more than one user. A client PUT that commits between + // these two statements is lost to the stale re-read — reverting read + // progress or a status change, and moving updated_at because the stored + // value now differs. Accepted for a single-user deployment: the window is + // milliseconds and the loser is one poll cycle. cur, found, err := p.store.Get(b.Key) if err != nil { log.Printf("latest poll %q: reread: %v", b.Key, err) diff --git a/backend/web.go b/backend/web.go index 7a5487d..61f5840 100644 --- a/backend/web.go +++ b/backend/web.go @@ -262,6 +262,13 @@ func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bo // 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 { -- 2.52.0