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 @@ + {{if eq .Status "finished"}} + + {{else}} + {{if eq .Status "archived"}} + + {{else}} + + {{end}} + + {{end}} +
+
@@ -960,6 +1011,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;