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/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.
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/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/main_test.go b/backend/main_test.go
index 90145bb..dd87c8e 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,81 @@ 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)
+ }
+ })
+ }
+}
+
+// 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/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/store.go b/backend/store.go
index d75916c..e3df5d2 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,13 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
if latestChapterNum.Valid {
b.LatestChapterNum = &latestChapterNum.Float64
}
+ // 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 != statusReading && b.Status != statusArchived && b.Status != statusFinished {
+ b.Status = statusReading
+ }
return b, nil
}
@@ -242,9 +265,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 +286,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 +294,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)
}
@@ -293,10 +328,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 6102db9..619a5ff 100644
--- a/backend/store_test.go
+++ b/backend/store_test.go
@@ -722,3 +722,186 @@ 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)
+ }
+}
+
+// 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{
+ 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)
+ }
+}
+
+// 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")
+ }
+}
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 @@
Nothing new. Every series is caught up to its latest chapter.
+{{else if eq .Tab "archived"}} +Nothing archived. Shelve a series to park it here — it keeps getting checked for new chapters.
+{{else if eq .Tab "finished"}} +Nothing finished yet.
{{else}}Nothing here yet. Bookmarks appear once the userscript records a chapter. diff --git a/backend/web.go b/backend/web.go index 7d88a4e..61f5840 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)) } @@ -145,38 +146,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 } @@ -248,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 { @@ -270,6 +291,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 b744162..edbf541 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -308,8 +308,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) { body := rr.Body.String() want := `hx-target="[id='card-asura:solo']"` - if strings.Count(body, want) != 3 { - t.Fatalf("body has %d occurrences of %s, want 3 (favorite button, delete button, chapter form)", + if strings.Count(body, want) != 5 { + t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)", strings.Count(body, want), want) } if strings.Contains(body, `hx-target="#card-asura:solo"`) { @@ -513,3 +513,220 @@ 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") + } +} + +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) + } +} + +func TestCardShowsStatusControls(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + cases := []struct { + tab string + want, dontWant []string + }{ + // A series being read can be shelved or completed, not restored. + {"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil}, + // An archived one can come back or be completed. + {"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil}, + // A finished one can only come back. + {"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}}, + } + 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) + + body := rr.Body.String() + for _, w := range tc.want { + if !strings.Contains(body, w) { + t.Fatalf("tab %s missing control %s", tc.tab, w) + } + } + for _, d := range tc.dontWant { + if strings.Contains(body, d) { + t.Fatalf("tab %s offered %s", tc.tab, d) + } + } + }) + } +} + +func TestAppRendersNewTabs(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + for _, want := range []string{`href="/?tab=archived"`, `href="/?tab=finished"`} { + if !strings.Contains(rr.Body.String(), want) { + t.Fatalf("app page missing %s", want) + } + } +} diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index a50c6f6..ee2d1d8 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Manga Bookmark Sync // @namespace mangabm -// @version 1.2.0 +// @version 1.3.0 // @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). // @author you // @match https://asuracomic.net/* @@ -19,6 +19,7 @@ // ============================================================ const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN + const WEB_BASE = "https://manga.violetcrown.my.id"; // the browser UI, for the panel's nav chips // Safe in Bromite's isolated world: the page's own JS cannot read these. const CACHE_KEY = "mangabm:cache"; @@ -270,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(); @@ -325,6 +332,12 @@ saveCache(state.list); } + // A list cached by an older version has no status field, and a row the + // server defaulted has "reading" — both mean the same thing here. + function statusOf(b) { + return b.status || "reading"; + } + // ============================================================ // Mutations (optimistic: update UI/cache first, then sync) // ============================================================ @@ -415,6 +428,26 @@ } } + // Archive parks a series: it leaves All and Favourites but the server keeps + // polling it for new chapters. "finished" is deliberately not reachable from + // here — the API rejects that value, it is a web-UI decision. + async function toggleArchive(key) { + const existing = state.byKey[key]; + if (!existing) return; + const next = statusOf(existing) === "archived" ? "reading" : "archived"; + const bm = Object.assign({}, existing, { status: next }); + upsertLocal(bm); + render(); + try { + 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"); + } catch (e) { + toast("Archive failed — retry when online", true); + } + } + // Records the newest chapter a site has published. Silent: this fires from // page visits and background checks the user did not ask for, and it never // reorders the list — updated_at is a candidate the server discards unless @@ -462,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); @@ -551,7 +585,7 @@ // ============================================================ let root, panelOpen = false; - let activeTab = "all"; // "all" | "favorites"; resets each page, by design + let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design function buildUI() { const host = document.createElement("div"); @@ -580,7 +614,7 @@ root.getElementById("backdrop").addEventListener("click", togglePanel); root.getElementById("closeBtn").addEventListener("click", togglePanel); - for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"]]) { + for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) { root.getElementById(id).addEventListener("click", () => { activeTab = tab; render(); @@ -752,22 +786,28 @@ ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." })); } - // Bookmark list. Favorites only narrows what is drawn — state.list keeps - // every bookmark, so favouriting one never removes it from "All". - const favorites = activeTab === "favorites"; - root.getElementById("tabAll").classList.toggle("active", !favorites); - root.getElementById("tabFav").classList.toggle("active", favorites); + // Tabs narrow what is drawn; state.list always holds every bookmark. + // Archived rows are hidden from All and Favourites, and finished ones — + // which only the web UI can set — are hidden from every tab. + for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) { + root.getElementById(id).classList.toggle("active", activeTab === tab); + } const listEl = root.getElementById("list"); listEl.innerHTML = ""; - const items = favorites ? state.list.filter((b) => b.favorite) : state.list; + const items = state.list.filter((b) => { + const s = statusOf(b); + if (activeTab === "archived") return s === "archived"; + if (s !== "reading") return false; + return activeTab !== "favorites" || b.favorite; + }); if (items.length === 0) { - listEl.appendChild( - el("div", { - class: "empty", - text: favorites ? "No favourites yet." : "No bookmarks yet.", - }) - ); + const empty = { + favorites: "No favourites yet.", + archived: "Nothing archived.", + all: "No bookmarks yet.", + }[activeTab]; + listEl.appendChild(el("div", { class: "empty", text: empty })); } for (const b of items) { listEl.appendChild(renderItem(b)); @@ -798,6 +838,11 @@ title: b.favorite ? "Remove from favourites" : "Add to favourites", onclick: () => toggleFavorite(b.key), }), + el("button", { + class: "btn small", + text: statusOf(b) === "archived" ? "Unarchive" : "Archive", + onclick: () => toggleArchive(b.key), + }), el("a", { class: "btn small", href: cont, text: "Continue" }), el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }), el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }), @@ -909,10 +954,16 @@ Manga Bookmarks +