From c9b1f2a3347aebbe048a0e24444321a1dccada84 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 09:04:24 +0700 Subject: [PATCH 1/8] Latest Chapter correction: one numeric input, owner-gated (#149) --- backend/internal/store/admin.go | 19 ++- .../migrations/0015_latest_correction.sql | 4 + backend/internal/store/store.go | 40 +++++- backend/internal/store/store_test.go | 130 +++++++++++++++++ backend/internal/web/admin.go | 1 + backend/internal/web/admin_series.go | 53 +++++++ backend/internal/web/admin_series_detail.go | 18 ++- backend/internal/web/static/admin.css | 21 +++ .../internal/web/templates/series-detail.html | 23 ++- backend/web_test.go | 133 ++++++++++++++++++ 10 files changed, 423 insertions(+), 19 deletions(-) create mode 100644 backend/internal/store/migrations/0015_latest_correction.sql diff --git a/backend/internal/store/admin.go b/backend/internal/store/admin.go index 7e78924..376c3e1 100644 --- a/backend/internal/store/admin.go +++ b/backend/internal/store/admin.go @@ -41,7 +41,8 @@ type SeriesFilter struct { // must never leave the store package — so the projection does not select it, // and only the anonymous boolean in raisedByReaderAnswer crosses it. const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address, - s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at` + s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at, + s.latest_corrected_at` // raisedByReaderAnswer answers "did a Reader's report set this number" without // naming which Reader. Kept apart from adminSeriesColumns so the column list — @@ -71,9 +72,15 @@ type AdminSeries struct { // ForcePollAt is the owner's "check now" request stamp (issue #146), zero // meaning never asked. Pending is derived, never stored: a request is // pending while ForcePollAt is newer than LatestCheckedAt. - ForcePollAt int64 - ReaderCount int - RaisedByReader bool // a Reader's report set LatestChapterNum + ForcePollAt int64 + // LatestCorrectedAt is the correction stamp (issue #149): non-zero means + // the Latest Chapter is the owner's, zero means never corrected. The + // provenance line (#152) derives from it, so the zero-means-never meaning + // is load-bearing. + LatestCorrectedAt int64 + ReaderCount int + RaisedByReader bool // a Reader's report set LatestChapterNum + } // SeriesPage is one page of the owner's filtered Series list plus the count @@ -189,7 +196,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) { `+where+` GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address, s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, - s.force_poll_at, s.latest_raised_by + s.force_poll_at, s.latest_corrected_at, s.latest_raised_by `+having+` ORDER BY s.latest_checked_at, s.site, s.series_id LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...) @@ -264,7 +271,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) { if err := scan( &a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress, &a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt, - &a.ForcePollAt, + &a.ForcePollAt, &a.LatestCorrectedAt, &a.RaisedByReader, &a.ReaderCount, &total, ); err != nil { return AdminSeries{}, 0, err diff --git a/backend/internal/store/migrations/0015_latest_correction.sql b/backend/internal/store/migrations/0015_latest_correction.sql new file mode 100644 index 0000000..918ce61 --- /dev/null +++ b/backend/internal/store/migrations/0015_latest_correction.sql @@ -0,0 +1,4 @@ +-- latest_corrected_at is the "the current Latest Chapter is the owner's" stamp +-- (#149). Written by the Correction; zeroed by every machine write of the +-- value. Zero means never corrected. +ALTER TABLE series ADD COLUMN latest_corrected_at bigint NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index da4dd0d..cd519a9 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -904,6 +904,11 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) { // xmax is zero only on a row this statement inserted, which is how a // Series nobody had bookmarked before is told apart from one that already // existed — DO UPDATE returns a row either way. + // latest_corrected_at is the one clause conditional on the value moving + // (#149): after a Correction a Reader's cached row holds the corrected + // number and resends it on the next Progress PUT, so unconditional + // zeroing would erase the fact while the value is still the owner's. The + // stamp survives a same-number PUT and dies the moment the number moves. var created bool if err := tx.QueryRow(` INSERT INTO series (site, series_id, title, series_url, kind, @@ -914,7 +919,10 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) { ON CONFLICT (site, series_id) DO UPDATE SET kind=excluded.kind, latest_chapter=excluded.latest_chapter, - latest_chapter_num=excluded.latest_chapter_num + latest_chapter_num=excluded.latest_chapter_num, + latest_corrected_at = CASE + WHEN series.latest_chapter_num IS DISTINCT FROM excluded.latest_chapter_num + THEN 0 ELSE series.latest_corrected_at END RETURNING xmax = 0`, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Kind, b.LatestChapter, latestNum).Scan(&created); err != nil { @@ -1320,10 +1328,13 @@ func (s *Store) LatestCheckedAt(site, seriesID string) (int64, error) { // SetLatestChapter records the newest chapter the poll found on a series page. // The poller walks Series rather than Bookmarks, so this is a series-level // write: the row is shared, and updating it once refreshes every bookmark that -// joins to it. Touching a missing series is not an error. +// joins to it. Touching a missing series is not an error. The correction stamp +// is zeroed unconditionally: checkOne only calls this when the number differs, +// so a second copy of the condition would drift (#149). func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) error { if _, err := s.db.Exec( - `UPDATE series SET latest_chapter = $3, latest_chapter_num = $4 + `UPDATE series SET latest_chapter = $3, latest_chapter_num = $4, + latest_corrected_at = 0 WHERE site = $1 AND series_id = $2`, site, seriesID, label, num); err != nil { return fmt.Errorf("set latest chapter %s:%s: %w", site, seriesID, err) @@ -1331,8 +1342,27 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro return nil } -// RecordSighting notes that a Reader's browser reported this Series' Latest -// Chapter, which is the half of a Sighting the client body cannot express +// CorrectLatestChapter makes the Latest Chapter the owner's: one UPDATE +// carrying the number, the derived label and the correction stamp. The label +// shape is the poller's and the userscript's ("Chapter " + the number as +// printed), so chapterLeadIn strips it and the UI renders "Ch N" with no +// special case. latest_checked_at is not touched: a Correction is not a check. +// A raising Reader is cleared without judgement: the number is the owner's +// now, and no Sighting counter moves (spec #135). +func (s *Store) CorrectLatestChapter(site, seriesID string, num float64, at int64) error { + if _, err := s.db.Exec( + `UPDATE series SET + latest_chapter = $3, + latest_chapter_num = $4, + latest_corrected_at = $5, + latest_raised_by = NULL + WHERE site = $1 AND series_id = $2`, + site, seriesID, "Chapter "+strconv.FormatFloat(num, 'f', -1, 64), num, at); err != nil { + return fmt.Errorf("correct latest chapter %s:%s: %w", site, seriesID, err) + } + return nil +} + // (issue #103). It must be called *before* the Upsert that stores the reported // value: the raise test compares against what is still on the row, and after // the Upsert there is nothing left to compare with. A Series that does not diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 4aa3b9d..8f17670 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -1899,3 +1899,133 @@ func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) { t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due) } } + +// A Correction writes the number, the derived label and the stamp, clears the +// raising Reader, and never touches either Sighting counter or the check +// stamp — a Correction is not a check and never judges a Reader (#149). +func TestCorrectLatestChapterStampsClearsAndDoesNotTouchCheckOrMarks(t *testing.T) { + s := newTestStore(t) + other := secondReader(t, s) + seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 4321_000) + // A Reader raised the number, and carries a mark for it. + if err := s.RecordSighting(other, "asura", "solo", num2(3), 1000); err != nil { + t.Fatalf("RecordSighting: %v", err) + } + if _, err := s.db.Exec(` + UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2 + WHERE id = $1`, other); err != nil { + t.Fatalf("mark reader: %v", err) + } + + if err := s.CorrectLatestChapter("asura", "solo", 12.5, 9000); err != nil { + t.Fatalf("CorrectLatestChapter: %v", err) + } + + var chapter string + var num float64 + var stamp, checkedAt int64 + var raisedBy any + if err := s.db.QueryRow(` + SELECT latest_chapter, latest_chapter_num, latest_corrected_at, + latest_checked_at, latest_raised_by + FROM series WHERE site = 'asura' AND series_id = 'solo'`). + Scan(&chapter, &num, &stamp, &checkedAt, &raisedBy); err != nil { + t.Fatalf("read back: %v", err) + } + if chapter != "Chapter 12.5" { + t.Errorf("latest_chapter = %q, want the derived label %q", chapter, "Chapter 12.5") + } + if num != 12.5 { + t.Errorf("latest_chapter_num = %v, want 12.5", num) + } + if stamp != 9000 { + t.Errorf("latest_corrected_at = %d, want 9000", stamp) + } + if checkedAt != 4321_000 { + t.Errorf("latest_checked_at = %d, want the untouched 4321000", checkedAt) + } + if raisedBy != nil { + t.Errorf("latest_raised_by = %v, want the attribution cleared", raisedBy) + } + + readers, err := s.Readers() + if err != nil { + t.Fatalf("Readers: %v", err) + } + for _, r := range readers { + if r.ID == other && (r.Agreements != 5 || r.Disagreements != 2) { + t.Errorf("raising reader's marks = %+v, want agreements 5, disagreements 2 unchanged", r) + } + } +} + +// The stamp follows the number (spec #135): an Upsert resending the corrected +// value — a Reader's cached row after a correction — keeps it, and an Upsert +// that actually moves the number kills it. Unconditional zeroing would erase +// the fact while the value is still the owner's; that is the whole point of +// the clause. +func TestUpsertCorrectionStampFollowsTheNumber(t *testing.T) { + s := newTestStore(t) + base := Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga, + SeriesURL: "https://asurascans.com/comics/solo", UpdatedAt: 1000, + } + if _, err := s.Upsert(s.OwnerID(), base); err != nil { + t.Fatalf("seed: %v", err) + } + if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil { + t.Fatalf("CorrectLatestChapter: %v", err) + } + + // Same number back: the value is still the owner's. + same := base + same.LatestChapterNum = num2(5) + if _, err := s.Upsert(s.OwnerID(), same); err != nil { + t.Fatalf("same-number upsert: %v", err) + } + if got := s.latestCorrectedAt(t, "asura", "solo"); got != 9000 { + t.Fatalf("stamp after same-number Upsert = %d, want 9000 kept", got) + } + + // A different number: a machine (or a Reader) wrote the value. + moved := base + moved.LatestChapterNum = num2(7) + if _, err := s.Upsert(s.OwnerID(), moved); err != nil { + t.Fatalf("moved upsert: %v", err) + } + if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 { + t.Fatalf("stamp after moved Upsert = %d, want zeroed", got) + } +} + +// The poller's chapter setter zeroes the stamp unconditionally: checkOne only +// calls it when the number differs, so the condition lives upstream and a +// second copy here would drift (#149). +func TestSetLatestChapterZeroesCorrectionStamp(t *testing.T) { + s := newTestStore(t) + seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0) + if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil { + t.Fatalf("CorrectLatestChapter: %v", err) + } + if err := s.SetLatestChapter("asura", "solo", "Chapter 6", 6); err != nil { + t.Fatalf("SetLatestChapter: %v", err) + } + if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 { + t.Fatalf("stamp after a machine write = %d, want zeroed", got) + } +} + +// latestCorrectedAt reads the stamp column for the assertion above. +func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 { + t.Helper() + var stamp int64 + if err := s.db.QueryRow( + `SELECT latest_corrected_at FROM series WHERE site = $1 AND series_id = $2`, + site, seriesID).Scan(&stamp); err != nil { + t.Fatalf("read stamp: %v", err) + } + return stamp +} + +// num2 boxes a chapter number for the Bookmark fields that take a pointer. +func num2(f float64) *float64 { return &f } diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index ca39e8c..bb22fe7 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -47,6 +47,7 @@ func (h *Handler) adminRoutes() []adminRoute { {"GET /admin/series", h.adminSeries}, {"GET /admin/series/{key}", h.adminSeriesDetail}, {"POST /admin/series/{key}/poll", h.adminSeriesPoll}, + {"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest}, {"POST /admin/lanes/{site}/pause", h.adminLanePause}, {"POST /admin/lanes/{site}/resume", h.adminLaneResume}, {"GET /ui/admin/lanes", h.uiLanes}, diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index e9099ad..ef536aa 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -3,6 +3,7 @@ package web import ( "fmt" "log" + "math" "net/http" "net/url" "strconv" @@ -172,6 +173,58 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) { h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now())) } +// adminSeriesCorrectLatest is the Latest Chapter correction: the owner types +// one number and the Series' Latest Chapter becomes it, stamped as a +// Correction. The number must be a finite float greater than zero — a +// non-numeric, zero or negative value answers 400 and never reaches the +// store, because a bad value would become every Reader's problem. The press +// answers with the freshly rendered meta fragment, so the figures describe +// the state after the press. The owner gate is the route's, not this +// handler's; the body is capped like the API path caps its bodies. +func (h *Handler) adminSeriesCorrectLatest(w http.ResponseWriter, r *http.Request) { + site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") + if !ok || site == "" || seriesID == "" { + http.Error(w, "bad series key", http.StatusBadRequest) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<16) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + num, err := strconv.ParseFloat(r.PostFormValue("chapter"), 64) + if err != nil || math.IsNaN(num) || math.IsInf(num, 0) || num <= 0 { + http.Error(w, "chapter must be a finite number greater than zero", http.StatusBadRequest) + return + } + if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil { + log.Printf("series correction %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } else if !found { + http.NotFound(w, r) + return + } + if err := h.store.CorrectLatestChapter(site, seriesID, num, time.Now().UnixMilli()); err != nil { + log.Printf("series correction %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + // Re-read after the write: the answer must describe the state after the + // press, so the marker reads "corrected just now". + a, found, err := h.adminSeriesByKey(site, seriesID) + if err != nil { + log.Printf("series correction %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a)) +} + // seriesListView assembles one Series list view from the request's query // string. An unknown filter value is the absent All case, never an error: the // select's options are not the only way this URL can be reached. diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 74810aa..d905906 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -24,14 +24,17 @@ type seriesDetailView struct { Cover string Chapter string // Latest Chapter number, or "—" before the first capture Checked string // how long ago the poller last checked, or "never" - Readers int + // Corrected is the correction marker's text, "" while no Correction + // stands: "corrected ago" — the copy that says the value is the + // owner's, and it dies with the stamp (a machine write of the number). + Corrected string + Readers int // Marks, one per hygiene fact, rendered only while it holds. Unpollable bool // no SeriesURL to fetch NoCover bool Orphan bool // no Reader holds the Series SightingRaised bool // a Reader's Sighting set the Latest Chapter - // Poll is the Check now control and the pending marker (issue #146): the // same derivation and visibility as the list row. CanPoll is false on a // Series with no page to fetch and on an orphan; Pending is derived — @@ -122,5 +125,16 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { } else { v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt)) } + v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt) return v } + +// correctedAge is the correction marker's text: "corrected ago" while +// the stamp is set, "" when zero — zero means never corrected, and the marker +// must not read as history once a machine wrote the number. +func correctedAge(now time.Time, at int64) string { + if at == 0 { + return "" + } + return "corrected " + since(now, time.UnixMilli(at)) +} diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index eb54abd..dd4f23e 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -720,6 +720,27 @@ margin-top: 8px; } +.admin-sheet .dform input { + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--field-line); + background: var(--ink); + color: var(--paper); + font: 500 14px var(--font-mono); + outline: none; +} +/* Focus follows the chapter form's idiom — paper, not heat: a red border on + a valid number field reads as "invalid". */ +.admin-sheet .dform input:focus { + border-color: var(--paper); +} +.admin-sheet .dform .hint { + margin: 0; + color: var(--mute-2); + font: 500 12px/1.4 var(--font-mono); + letter-spacing: .04em; +} + .admin-sheet .pausebar { display: flex; align-items: center; diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html index 3866e16..50cc2fb 100644 --- a/backend/internal/web/templates/series-detail.html +++ b/backend/internal/web/templates/series-detail.html @@ -1,8 +1,9 @@ {{/* Per-Series page: one address per Series, keyed ":" so the list row is one hop from it. Everything here is a Series-level fact plus the anonymous Reader count. The Check now control lands in its own .dform - below the (empty) .detail-grid; the pending marker rides the meta line - with the other marks. */}} + below the .detail-grid; the correction form is the grid's first column, + and the grid's second waits for the series-URL repair (#151). The pending + and corrected markers ride the meta line with the other marks. */}} {{define "series-detail"}} ← Series

{{.Title}}

@@ -10,7 +11,16 @@ {{if .Cover}}
{{else}}
{{end}} {{template "series-detail-meta" .}} -
+
+
+

Correct latest chapter

+

The next successful Poll overwrites this value.

+
+ + +
+
+
{{if .CanPoll}}
@@ -18,14 +28,15 @@ {{end}} {{end}} -{{/* series-detail-meta is the meta line, and the answer a Check now press on - the detail page swaps into its place: the same marks, re-rendered after - the stamp so the pending marker shows. */}} +{{/* series-detail-meta is the meta line, and the answer a Check now or + correction press on the detail page swaps into its place: the same marks, + re-rendered after the stamp so the pending and corrected markers show. */}} {{define "series-detail-meta"}}
ch {{.Chapter}} checked {{.Checked}} {{.Readers}} readers + {{if .Corrected}}{{.Corrected}}{{end}} {{if .Pending}}{{.Requested}}{{end}} {{if .Unpollable}}unpollable{{end}} {{if .NoCover}}no cover{{end}} diff --git a/backend/web_test.go b/backend/web_test.go index 3d701f5..608ab00 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -3238,3 +3238,136 @@ func TestSeriesPollCapsBody(t *testing.T) { t.Errorf("an oversized body still stamped the request:\n%s", body) } } +// The correction route validates at the boundary: a non-numeric, zero, +// negative or non-finite chapter answers 400 and never reaches the store, and +// a finite number greater than zero stores the number, the derived label and +// the stamp. The answer is the freshly rendered meta fragment, so the figures +// describe the state after the press (#149). +func TestCorrectLatestChapterRoute(t *testing.T) { + st, dsn := newTestStoreURL(t) + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open %s: %v", dsn, err) + } + defer db.Close() + seedSeriesRow(t, st, db, seriesRowSeed{ + key: "asura:solo", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: floatPtr(3), + }) + router := newRouter(st, testConfig()) + cookie := sessionCookie(t, st) + + for _, body := range []string{ + "chapter=abc", "chapter=", "chapter=0", "chapter=-1", "chapter=NaN", "chapter=Inf", + } { + req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("POST latest with body %q: status = %d, want 400", body, rr.Code) + } + } + + // Nothing reached the store: the seeded number stands, unstamped. + var num float64 + var stamp int64 + if err := db.QueryRow(` + SELECT latest_chapter_num, latest_corrected_at + FROM series WHERE site = 'asura' AND series_id = 'solo'`). + Scan(&num, &stamp); err != nil { + t.Fatalf("read back: %v", err) + } + if num != 3 || stamp != 0 { + t.Fatalf("after 400s the row is num %v, stamp %d; want 3, 0", num, stamp) + } + + // A good press stores the number, the derived label and the stamp, and + // answers with the meta fragment describing the state after the press. + req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=12.5")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("POST latest status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + if !strings.Contains(body, `id="detail-meta"`) { + t.Errorf("correction answer is not the meta fragment:\n%s", body) + } + if !strings.Contains(body, `corrected `) { + t.Errorf("correction answer lacks the fresh corrected marker:\n%s", body) + } + var label string + if err := db.QueryRow(` + SELECT latest_chapter, latest_chapter_num, latest_corrected_at + FROM series WHERE site = 'asura' AND series_id = 'solo'`). + Scan(&label, &num, &stamp); err != nil { + t.Fatalf("read back: %v", err) + } + if label != "Chapter 12.5" || num != 12.5 { + t.Errorf("stored = %q, %v; want the derived label and 12.5", label, num) + } + if stamp == 0 { + t.Error("stamp = 0, want the correction stamp written") + } +} + +// The detail page offers the one-input correction with the plain copy, and +// the corrected marker rides the meta line while the stamp is set — then +// disappears the moment a machine writes the number (#149). +func TestAdminSeriesDetailCorrectionMarker(t *testing.T) { + st, dsn := newTestStoreURL(t) + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open %s: %v", dsn, err) + } + defer db.Close() + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:solo", url: "u", checkedAt: 9000, bookmarks: 1}) + router := newRouter(st, testConfig()) + cookie := sessionCookie(t, st) + + body := seriesDetailPage(t, router, st, "asura:solo") + for _, want := range []string{ + `name="chapter"`, + `hx-post="/admin/series/asura:solo/latest"`, + "The next successful Poll overwrites this value.", + } { + if !strings.Contains(body, want) { + t.Errorf("detail page lacks %q:\n%s", want, body) + } + } + if strings.Contains(body, "corrected ") { + t.Errorf("uncorrected detail already carries the marker:\n%s", body) + } + + // The press lands the marker on the meta line. + req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=7")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("POST latest status = %d, want 200", rr.Code) + } + body = seriesDetailPage(t, router, st, "asura:solo") + if !strings.Contains(body, `corrected `) { + t.Errorf("detail page lacks the corrected marker after the press:\n%s", body) + } + if !strings.Contains(body, "ch 7") { + t.Errorf("detail page does not show the corrected number:\n%s", body) + } + + // A machine write (the poller's setter) kills the marker. + if err := st.SetLatestChapter("asura", "solo", "Chapter 8", 8); err != nil { + t.Fatalf("SetLatestChapter: %v", err) + } + body = seriesDetailPage(t, router, st, "asura:solo") + if strings.Contains(body, "corrected ") { + t.Errorf("marker survives a machine write:\n%s", body) + } + if !strings.Contains(body, "ch 8") { + t.Errorf("detail page does not show the machine-written number:\n%s", body) + } +} -- 2.52.0 From b9fc83217d55fd8f6d05fb616c227f7a9c4678a7 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 09:05:53 +0700 Subject: [PATCH 2/8] #150: address covers by bytes; ReplaceSeriesCover --- backend/cover_test.go | 8 +- backend/internal/latest/acquire_test.go | 14 +- backend/internal/latest/poller_test.go | 24 ++- backend/internal/latest/smoke_image_test.go | 20 ++- .../migrations/0009_series_cover_address.sql | 13 +- backend/internal/store/store.go | 110 ++++++++++---- backend/internal/store/store_test.go | 140 ++++++++++++++++-- docs/adr/0014-cover-addresses-from-bytes.md | 86 +++++++++++ 8 files changed, 338 insertions(+), 77 deletions(-) create mode 100644 docs/adr/0014-cover-addresses-from-bytes.md diff --git a/backend/cover_test.go b/backend/cover_test.go index 9c3c93f..87db88a 100644 --- a/backend/cover_test.go +++ b/backend/cover_test.go @@ -32,7 +32,7 @@ func TestPublicCoverServesStoredBytesUnauthenticated(t *testing.T) { // The wire URL is what a client actually requests, so the path under test // is taken from it rather than rebuilt by hand. - wire := st.CoverWireURL(store.CoverAddress(sourceURL)) + wire := st.CoverWireURL(store.CoverAddressForBytes([]byte("\x00webp-bytes"))) path, ok := strings.CutPrefix(wire, testCoverBaseURL) if !ok { t.Fatalf("wire URL %q is not on the public origin %q", wire, testCoverBaseURL) @@ -57,7 +57,7 @@ func TestPublicCoverServesStoredBytesUnauthenticated(t *testing.T) { func TestPublicCoverRejectsUnknownAddress(t *testing.T) { srv, _ := newWebTestServer(t, testConfig()) cases := map[string]string{ - "unknown": "/covers/" + store.CoverAddress("https://cdn.example/never-stored.jpg"), + "unknown": "/covers/" + store.CoverAddressForBytes([]byte("never-stored")), "malformed": "/covers/not-an-address", "traversal": "/covers/../../etc/passwd", "empty": "/covers/", @@ -86,7 +86,7 @@ func TestPublicCoverNeverEchoesNonImage(t *testing.T) { } // A legitimate row, then the content type flipped behind the store's back: // the bytes exist at the address, so only the type is hostile. - address := store.CoverAddress(sourceURL) + address := store.CoverAddressForBytes([]byte("`, bookmarks: 1, + }) + router := newRouter(st, testConfig()) + + body := seriesDetailPage(t, router, st, "asura:solo") + for _, want := range []string{ + `name="series_url"`, + `hx-post="/admin/series/asura:solo/series-url"`, + `value="https://asurascans.com/stories/solo"`, + "A Site-wide host change", "SQL migration", + } { + if !strings.Contains(body, want) { + t.Errorf("detail page lacks %q:\n%s", want, body) + } + } + + // The stored value that is markup stays markup in the input's value + // attribute, never executable HTML. + body = seriesDetailPage(t, router, st, "asura:evil") + if strings.Contains(body, ``) { + t.Errorf("repair input renders stored URL unescaped:\n%s", body) + } + if !strings.Contains(body, `value="https://asurascans.com/x"><script>alert(1)</script>"`) { + t.Errorf("repair input does not carry the escaped stored URL:\n%s", body) + } +} -- 2.52.0 From 35a86f5eb9ff0f9602c39e1ebd39a1d6d08f4cd4 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 09:23:18 +0700 Subject: [PATCH 5/8] Correct the three-outcome comment; keep doc comment attached to storeCover (#153) --- backend/internal/latest/poller.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index 3bb3f35..10a7d70 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -143,7 +143,6 @@ func (p *Poller) prefetchCover(ctx context.Context, sr store.Series) { // fill-only write for an ordinary pass, a write-through for a forced one // (issue #135). Every failure is logged against the Series and swallowed so // the chapter poll cannot see it. - func (p *Poller) storeCover(ctx context.Context, sr store.Series, sourceURL string) { bytes, contentType, err := fetchCoverBytes(ctx, sourceURL, p.CoverFetch, p.CoverBytesFetch) if err != nil { @@ -157,9 +156,10 @@ func (p *Poller) storeCover(ctx context.Context, sr store.Series, sourceURL stri return } // The forced write replaces whether or not a Cover exists, and the row - // then tells three outcomes apart: a blank filled, identical artwork - // re-served — an honest no-op — or a replacement that strands previous, - // which the next wave reclaims at this exact call site. + // then tells the three outcomes apart: a blank filled, identical artwork + // re-served — an honest no-op — or a replacement whose previous address + // is stranded: its bytes stay served under the covers table (ADR-0014), + // the row just no longer points at them. previous, current, err := p.Store.ReplaceSeriesCover(sr.Site, sr.SeriesID, sourceURL, bytes, contentType) if err != nil { log.Printf("latest poll %q: persist cover: %v", sr.Key(), err) -- 2.52.0 From a4491babed72a5da0a06d1f69cea85125a4e48dc Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 09:32:25 +0700 Subject: [PATCH 6/8] Latest Chapter provenance line naming the actor class (#152) --- backend/internal/web/admin_series_detail.go | 24 +++++++++ .../internal/web/admin_series_detail_test.go | 34 ++++++++++++ .../internal/web/templates/series-detail.html | 7 ++- backend/web_test.go | 54 +++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 backend/internal/web/admin_series_detail_test.go diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 8178aba..4166f9a 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -33,6 +33,12 @@ type seriesDetailView struct { // owner's, and it dies with the stamp (a machine write of the number). Corrected string + // Provenance is the actor class behind the current Chapter: "correction", + // "sighting" or "machine read"; "" while the Series was never read, when + // the line is not rendered. Derived from the same anonymous stamps the + // marks above read — no Reader identity crosses here. + Provenance string + // Marks, one per hygiene fact, rendered only while it holds. Unpollable bool // no SeriesURL to fetch NoCover bool @@ -130,6 +136,24 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt)) } v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt) + + // Provenance: the actor class behind the current number, evaluated in the + // order the classes outrank one another — the owner's stamp, which a + // Correction leaves standing and a machine write clears (issue #149); a + // raising Reader, which a Correction drops; then any check stamp at all. + // An Acquisition reads as a machine read because it stamps + // latest_checked_at exactly as a Poll does, so the two are + // indistinguishable the moment it finishes; telling them apart would need + // the column this project declines to add (spec #135), and the one + // actionable case — acquired once, never read again — is already the + // unchecked filter. + if a.LatestCorrectedAt != 0 { + v.Provenance = "correction" + } else if a.RaisedByReader { + v.Provenance = "sighting" + } else if a.LatestCheckedAt != 0 { + v.Provenance = "machine read" + } return v } diff --git a/backend/internal/web/admin_series_detail_test.go b/backend/internal/web/admin_series_detail_test.go new file mode 100644 index 0000000..e244ead --- /dev/null +++ b/backend/internal/web/admin_series_detail_test.go @@ -0,0 +1,34 @@ +package web + +import ( + "testing" + + "bookmarkmanager/backend/internal/store" +) + +// seriesDetailView derives the provenance line from the three stamps the +// admin projection already carries: the correction stamp outranks a raising +// Reader, which outranks a check stamp, and a Series with none of the three +// renders no line at all — it was never read, and no actor class is true of +// it. Acquisition stamps latest_checked_at exactly as a Poll does, so an +// acquired value lands in the same "machine read" class (#152). +func TestSeriesDetailViewProvenance(t *testing.T) { + cases := []struct { + name string + a store.AdminSeries + want string + }{ + {"correction stamp", store.AdminSeries{LatestCorrectedAt: 1}, "correction"}, + {"raising reader only", store.AdminSeries{RaisedByReader: true}, "sighting"}, + {"check stamp only", store.AdminSeries{LatestCheckedAt: 1}, "machine read"}, + {"correction outranks sighting", store.AdminSeries{LatestCorrectedAt: 1, RaisedByReader: true}, "correction"}, + {"none of the three", store.AdminSeries{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := (&Handler{}).seriesDetailView(tc.a).Provenance; got != tc.want { + t.Fatalf("Provenance = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html index c5a800c..71f63c8 100644 --- a/backend/internal/web/templates/series-detail.html +++ b/backend/internal/web/templates/series-detail.html @@ -38,10 +38,13 @@ {{/* series-detail-meta is the meta line, and the answer a Check now or correction press on the detail page swaps into its place: the same marks, - re-rendered after the stamp so the pending and corrected markers show. */}} + re-rendered after the stamp so the pending and corrected markers — and + the provenance line beside the number — describe the value they sit + next to. */}} {{define "series-detail-meta"}}
ch {{.Chapter}} + {{if .Provenance}}{{.Provenance}}{{end}} checked {{.Checked}} {{.Readers}} readers {{if .Corrected}}{{.Corrected}}{{end}} @@ -51,4 +54,4 @@ {{if .Orphan}}orphan{{end}} {{if .SightingRaised}}sighting-raised{{end}}
-{{end}} +{{end}} \ No newline at end of file diff --git a/backend/web_test.go b/backend/web_test.go index 5c048fe..2241186 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -2722,6 +2722,60 @@ func TestAdminSeriesDetailRendersMarks(t *testing.T) { } } +// The provenance line beside the chapter names the actor class behind the +// value — "machine read" for a checked Series, "correction" for the owner's +// stamp, "sighting" for a Reader-raised one — and appears nowhere in the +// rendered Series list: an actor class is context for the Series the owner is +// already looking at, never a population to sweep (#152). +func TestAdminSeriesDetailProvenanceLine(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + + seed(t, st, store.Bookmark{ + Key: "asura:machine", Site: "asura", SeriesID: "machine", + Title: "Machine", SeriesURL: "https://asurascans.com/series/machine", + Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45), + }) + if err := st.MarkLatestChecked("asura", "machine", time.Now().Add(-time.Hour).UnixMilli()); err != nil { + t.Fatalf("MarkLatestChecked: %v", err) + } + + seed(t, st, store.Bookmark{ + Key: "asura:hand", Site: "asura", SeriesID: "hand", + Title: "Hand", SeriesURL: "https://asurascans.com/series/hand", + Kind: "manga", LatestChapter: "12", LatestChapterNum: floatPtr(12), + }) + if err := st.CorrectLatestChapter("asura", "hand", 13, time.Now().UnixMilli()); err != nil { + t.Fatalf("CorrectLatestChapter: %v", err) + } + + seed(t, st, store.Bookmark{ + Key: "demonic:raised", Site: "demonic", SeriesID: "raised", + Title: "Raised", SeriesURL: "https://demonicscans.org/series/raised", + Kind: "manga", + }) + if err := st.RecordSighting(st.OwnerID(), "demonic", "raised", floatPtr(7), time.Now().UnixMilli()); err != nil { + t.Fatalf("RecordSighting: %v", err) + } + + for _, tc := range []struct{ key, want string }{ + {"asura:machine", "machine read"}, + {"asura:hand", "correction"}, + {"demonic:raised", "sighting"}, + } { + body := seriesDetailPage(t, router, st, tc.key) + if !strings.Contains(body, ""+tc.want+"") { + t.Errorf("detail %s lacks the %q provenance line:\n%s", tc.key, tc.want, body) + } + } + + listBody := adminSeriesPage(t, router, st, "") + for _, word := range []string{"machine read", "correction", "sighting"} { + if strings.Contains(listBody, ""+word+"") { + t.Errorf("Series list carries a %q provenance line:\n%s", word, listBody) + } + } +} + // A well-formed key naming no row is a 404, and so is a key with no ":", // an empty Site or an empty SeriesID — the detail page never answers 500 for // an address nobody can reach. -- 2.52.0 From a4ea80dcc24d0006e5879b8852790b9d7b170027 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 09:34:02 +0700 Subject: [PATCH 7/8] Cover byte reclamation: one guarded helper, file first, covers row last (#154) --- backend/internal/latest/poller.go | 7 +- backend/internal/latest/poller_test.go | 137 ++++++++++++++++++++ backend/internal/store/store.go | 43 ++++++- backend/internal/store/store_test.go | 165 ++++++++++++++++++++++++- 4 files changed, 345 insertions(+), 7 deletions(-) diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index b10ba02..1c6ae7c 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -158,8 +158,8 @@ func (p *Poller) storeCover(ctx context.Context, sr store.Series, sourceURL stri // The forced write replaces whether or not a Cover exists, and the row // then tells the three outcomes apart: a blank filled, identical artwork // re-served — an honest no-op — or a replacement whose previous address - // is stranded: its bytes stay served under the covers table (ADR-0014), - // the row just no longer points at them. + // is stranded and reclaimed below. A failed reclaim is logged and the + // stranded bytes stay served until a later call reclaims them. previous, current, err := p.Store.ReplaceSeriesCover(sr.Site, sr.SeriesID, sourceURL, bytes, contentType) if err != nil { log.Printf("latest poll %q: persist cover: %v", sr.Key(), err) @@ -171,6 +171,9 @@ func (p *Poller) storeCover(ctx context.Context, sr store.Series, sourceURL stri case previous == current: log.Printf("latest poll %q: cover unchanged, the site re-serves the same bytes", sr.Key()) default: + if err := p.Store.ReclaimCover(previous); err != nil { + log.Printf("latest poll %q: reclaim cover %s: %v", sr.Key(), previous, err) + } log.Printf("latest poll %q: cover replaced %s -> %s", sr.Key(), previous, current) } } diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go index 550160a..301225b 100644 --- a/backend/internal/latest/poller_test.go +++ b/backend/internal/latest/poller_test.go @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + "io/fs" "log" "os" "path/filepath" @@ -2558,3 +2559,139 @@ func TestRunOnceForcedPassWithoutCoverFetcherStillPolls(t *testing.T) { t.Fatalf("missing skipped-cover log; log:\n%s", logs.String()) } } + +// A forced replacement strands the previous address, and the poller reclaims +// it from the stranded branch: the old sharded file and covers row are both +// gone once the pass lands while the new bytes read back (issue #154). The +// identical-bytes no-op that follows reclaims nothing: previous == current +// there, and a reclamation would delete the Cover the pass just wrote. +func TestRunOnceForcedPassReclaimsSupersededCover(t *testing.T) { + coverDir := t.TempDir() + url := pgtest.URL(t) + s, err := store.Open(url, testOwner, coverDir, testCoverBaseURL) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + const ( + key = "asura:chronicles-of-the-demon-faction-f886a8af" + seriesID = "chronicles-of-the-demon-faction-f886a8af" + seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af" + first = "https://cdn.example/covers/first.jpg" + ) + if _, err := s.Upsert(s.OwnerID(), store.Bookmark{ + Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000, + }); err != nil { + t.Fatalf("seed: %v", err) + } + if err := s.SetSeriesCover("asura", seriesID, first, []byte("first"), "image/jpeg"); err != nil { + t.Fatalf("seed cover: %v", err) + } + stale := store.CoverAddressForBytes([]byte("first")) + stalePath := filepath.Join(coverDir, filepath.FromSlash(stale[:2]+"/"+stale[2:4]+"/"+stale)) + + at := time.UnixMilli(5_000_000) + if err := s.ForceSeriesPoll("asura", seriesID, at.Add(time.Hour).UnixMilli()); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + p := &Poller{ + Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200}, + CoverBytesFetch: &fakeBytesCoverFetcher{body: []byte("second"), contentType: "image/jpeg"}, + Now: func() time.Time { return at }, + } + p.runOnce(context.Background()) + p.waitCovers() + + // The stranded address is gone on disk and in SQL; the new one reads back. + if _, err := os.Stat(stalePath); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("stale sharded file after replacement = %v, want fs.ErrNotExist", err) + } + if _, _, ok, err := s.CoverByAddress(stale); err != nil || ok { + t.Fatalf("stale bytes after replacement = found %v err %v, want reclaimed", ok, err) + } + if body, _, ok, err := s.CoverByAddress(store.CoverAddressForBytes([]byte("second"))); err != nil || !ok || string(body) != "second" { + t.Fatalf("new bytes after replacement = found %v err %v, want served", ok, err) + } + + // The identical-bytes pass is an honest no-op and reclaims nothing. + if err := s.ForceSeriesPoll("asura", seriesID, at.Add(2*time.Hour).UnixMilli()); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + p.runOnce(context.Background()) + p.waitCovers() + current := store.CoverAddressForBytes([]byte("second")) + currentPath := filepath.Join(coverDir, filepath.FromSlash(current[:2]+"/"+current[2:4]+"/"+current)) + if _, err := os.Stat(currentPath); err != nil { + t.Fatalf("live sharded file after no-op pass = %v, want present", err) + } + if body, _, ok, err := s.CoverByAddress(current); err != nil || !ok || string(body) != "second" { + t.Fatalf("bytes after no-op pass = found %v err %v, want still served", ok, err) + } +} + +// A reclamation that fails must not fail the Poll: the failure is logged +// against the Series and the replacement still lands, so the stranded bytes +// stay reachable for a retry and the owner's act succeeded (issue #154). +func TestRunOnceForcedPassReclaimFailureDoesNotFailPoll(t *testing.T) { + coverDir := t.TempDir() + url := pgtest.URL(t) + s, err := store.Open(url, testOwner, coverDir, testCoverBaseURL) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + const ( + key = "asura:chronicles-of-the-fallen-f886a8af" + seriesID = "chronicles-of-the-fallen-f886a8af" + seriesURL = "https://asurascans.com/comics/chronicles-of-the-fallen-f886a8af" + first = "https://cdn.example/covers/first.jpg" + ) + if _, err := s.Upsert(s.OwnerID(), store.Bookmark{ + Key: key, Site: "asura", SeriesID: seriesID, SeriesURL: seriesURL, UpdatedAt: 1000, + }); err != nil { + t.Fatalf("seed: %v", err) + } + if err := s.SetSeriesCover("asura", seriesID, first, []byte("first"), "image/jpeg"); err != nil { + t.Fatalf("seed cover: %v", err) + } + // Make the stranded file unremovable: a non-empty directory in its place. + stale := store.CoverAddressForBytes([]byte("first")) + stalePath := filepath.Join(coverDir, filepath.FromSlash(stale[:2]+"/"+stale[2:4]+"/"+stale)) + if err := os.Remove(stalePath); err != nil { + t.Fatalf("clear file: %v", err) + } + if err := os.Mkdir(stalePath, 0o755); err != nil { + t.Fatalf("replace file with dir: %v", err) + } + if err := os.WriteFile(filepath.Join(stalePath, "blob"), []byte("x"), 0o644); err != nil { + t.Fatalf("fill dir: %v", err) + } + + at := time.UnixMilli(5_000_000) + if err := s.ForceSeriesPoll("asura", seriesID, at.Add(time.Hour).UnixMilli()); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + var logs strings.Builder + prev := log.Writer() + log.SetOutput(&logs) + t.Cleanup(func() { log.SetOutput(prev) }) + p := &Poller{ + Store: s, Fetch: &fakeFetcher{body: asuraSeriesFixture + asuraCoverFixture, status: 200}, + CoverBytesFetch: &fakeBytesCoverFetcher{body: []byte("second"), contentType: "image/jpeg"}, + Now: func() time.Time { return at }, + } + p.runOnce(context.Background()) + p.waitCovers() + + logged := logs.String() + if !strings.Contains(logged, "reclaim cover") { + t.Fatalf("failed reclamation not logged; log:\n%s", logged) + } + if !strings.Contains(logged, "cover replaced") { + t.Fatalf("replacement not reported after a failed reclaim; log:\n%s", logged) + } + got := readBookmark(t, s, key) + if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes([]byte("second")); got.Cover != want { + t.Fatalf("Cover = %q, want the replacement %q", got.Cover, want) + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 4321818..b403f6d 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -746,6 +746,42 @@ func (s *Store) putCover(sourceURL string, body []byte, contentType string) (str return address, nil } +// ReclaimCover permanently removes a Cover nothing references: the sharded +// file first, the covers row last. A blank address is a no-op, and so is any +// address a Series row still points at — byte-identical artwork is one row by +// construction (ADR-0014), so reclaiming one Series' stranded bytes must not +// blank another's. The file goes first because the covers row is the handle: +// an interrupted run stays findable in SQL — covers rows unreferenced by any +// series cover_address — and re-running finishes the job, whereas deleting +// the row first would leave a file nothing names. A concurrent Forced Poll +// repointing a live Series at this address between the guard and the unlink +// is the repairable case: the missing file reads as ok=false and the next +// pass re-installs it. Failures are returned, never logged here — the caller +// logs and carries on — and a failed unlink leaves the row in place for a +// retry. A whole-table sweep, if ever wanted, is one SQL query over covers, +// not a tree walk and not this function. +func (s *Store) ReclaimCover(address string) error { + if address == "" { + return nil + } + var referenced int + err := s.db.QueryRow(`SELECT 1 FROM series WHERE cover_address = $1 LIMIT 1`, address).Scan(&referenced) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("guard reclaim of cover %q: %w", address, err) + } + coverPath := filepath.Join(s.coverDir, filepath.FromSlash(coverRelativePath(address))) + if err := os.Remove(coverPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove cover file %q: %w", address, err) + } + if _, err := s.db.Exec(`DELETE FROM covers WHERE address = $1`, address); err != nil { + return fmt.Errorf("delete cover row %q: %w", address, err) + } + return nil +} + // GetCover returns the immutable object a source URL's own hash names. Rows // written before byte addressing (ADR-0014) are the only ones that ever reach // it; it hashes the URL, so a byte-addressed Cover is invisible to it. Missing @@ -825,9 +861,10 @@ func (s *Store) SetSeriesCover(site, seriesID, sourceURL string, body []byte, co // ("" if it had none) and current the address of the bytes just stored; both // are read and written in one transaction, so a concurrent replacement reports // the exact displacement. previous == current means the Site served identical -// artwork, an honest no-op; otherwise previous is stranded — its bytes stay -// served under the covers table (ADR-0014), the row just no longer points at -// them. +// artwork, an honest no-op; otherwise previous is stranded — the row no +// longer points at it, and reclaiming its bytes is the caller's separate act +// (the poller's replace path calls ReclaimCover on it). This write itself +// removes nothing. func (s *Store) ReplaceSeriesCover(site, seriesID, sourceURL string, body []byte, contentType string) (previous, current string, err error) { current, err = s.putCover(sourceURL, body, contentType) if err != nil { diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index a14be84..db286b9 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -5,6 +5,8 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "errors" + "io/fs" "os" "path/filepath" "strconv" @@ -2029,6 +2031,7 @@ func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 { // num2 boxes a chapter number for the Bookmark fields that take a pointer. func num2(f float64) *float64 { return &f } + // --- Cover addressing (ADR-0014): the address is the bytes' SHA-256 --- // The address is what makes a re-art visible at all, so the same bytes must @@ -2094,8 +2097,8 @@ func TestReplaceSeriesCover(t *testing.T) { sr.Cover != "https://cdn.asurascans.com/covers/solo-rebrand.webp" { t.Fatalf("series after replacement = %+v, want the new address and source URL", sr) } - // The replaced bytes stay served under their old address; nothing reclaims - // them in this ticket (the forced-poll wave does). + // The replaced bytes stay served under their old address: ReplaceSeriesCover + // itself reclaims nothing, reclamation is the caller's separate act (#154). if _, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("first-art"))); err != nil || !ok { t.Fatalf("superseded bytes = found %v, err %v, want still served", ok, err) } @@ -2179,3 +2182,161 @@ func TestSetSeriesURLWritesWhereUpsertIgnores(t *testing.T) { t.Fatalf("stored URL = %q, want %q", sr.SeriesURL, repair) } } + +// --- Cover byte reclamation (issue #154): one guarded helper, file first --- + +// coverShardPath is the on-disk location of one address's bytes, built the +// same way getCoverByAddress reads them. +func coverShardPath(t *testing.T, s *Store, address string) string { + t.Helper() + return filepath.Join(s.coverDir, filepath.FromSlash(coverRelativePath(address))) +} + +// ReclaimCover removes a Cover nothing references: the row alone is not the +// point — the sharded file must be gone too, because the file is the reclaimed +// disk space. +func TestReclaimCoverRemovesUnreferencedBytes(t *testing.T) { + store := newTestStore(t) + seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0) + if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil { + t.Fatalf("put cover: %v", err) + } + old := CoverAddressForBytes([]byte("old-art")) + if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil { + t.Fatalf("replace cover: %v", err) + } + + if err := store.ReclaimCover(old); err != nil { + t.Fatalf("ReclaimCover: %v", err) + } + if _, err := os.Stat(coverShardPath(t, store, old)); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("sharded path after reclaim = %v, want fs.ErrNotExist", err) + } + if _, _, ok, err := store.CoverByAddress(old); err != nil || ok { + t.Fatalf("covers row after reclaim = found %v err %v, want gone", ok, err) + } + // The live Cover survives the reclamation of the stranded one. + if body, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("new-art"))); err != nil || !ok || string(body) != "new-art" { + t.Fatalf("new bytes after reclaim = found %v err %v, want still served", ok, err) + } +} + +// The guard is the whole design: byte-identical artwork is one covers row by +// construction (ADR-0014), so a second Series pointing at the address must +// keep the bytes — reclaiming one Series' stranded artwork may not blank +// another's. +func TestReclaimCoverSparesReferencedAddress(t *testing.T) { + store := newTestStore(t) + seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0) + const src = "https://cdn.example/covers/shared.jpg" + addr := CoverAddressForBytes([]byte("shared-art")) + if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil { + t.Fatalf("put cover: %v", err) + } + + // One Series pointing at the address is enough for the guard. + if err := store.ReclaimCover(addr); err != nil { + t.Fatalf("ReclaimCover on a referenced address: %v", err) + } + if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" { + t.Fatalf("bytes after no-op = found %v err %v, want still served", ok, err) + } + if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil { + t.Fatalf("sharded file after no-op: %v, want present", err) + } + + // A second Series serving identical bytes shares the row by construction. + seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0) + if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil { + t.Fatalf("share cover: %v", err) + } + if err := store.ReclaimCover(addr); err != nil { + t.Fatalf("ReclaimCover on a shared address: %v", err) + } + if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" { + t.Fatalf("shared bytes after no-op = found %v err %v, want still served", ok, err) + } + if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil { + t.Fatalf("sharded file after shared no-op: %v, want present", err) + } +} + +// A blank address is the wire value for "no Cover" (ADR-0007), never a +// reclaimable one. +func TestReclaimCoverBlankAddressIsNoOp(t *testing.T) { + store := newTestStore(t) + if err := store.ReclaimCover(""); err != nil { + t.Fatalf("ReclaimCover(\"\") = %v, want nil", err) + } +} + +// An interrupted reclamation is the state the file-first order exists for: +// the row is the handle, so the unreferenced-covers query finds the torn +// Cover and re-running ReclaimCover finishes the job — a missing file is +// "already gone", which counts as success. +func TestReclaimCoverInterruptedRunIsFindableAndFinishes(t *testing.T) { + store := newTestStore(t) + seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0) + if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/torn.jpg", []byte("torn-art"), "image/jpeg"); err != nil { + t.Fatalf("put cover: %v", err) + } + torn := CoverAddressForBytes([]byte("torn-art")) + if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/new.jpg", []byte("new-art"), "image/jpeg"); err != nil { + t.Fatalf("replace cover: %v", err) + } + if err := os.Remove(coverShardPath(t, store, torn)); err != nil { + t.Fatalf("unlink mid-reclamation: %v", err) + } + + var found string + err := store.db.QueryRow(` + SELECT address FROM covers c + WHERE NOT EXISTS (SELECT 1 FROM series s WHERE s.cover_address = c.address) + LIMIT 1`).Scan(&found) + if err != nil || found != torn { + t.Fatalf("unreferenced-covers query = (%q, %v), want the torn row %q", found, err, torn) + } + + if err := store.ReclaimCover(torn); err != nil { + t.Fatalf("re-run over a missing file: %v", err) + } + if _, _, ok, err := store.CoverByAddress(torn); err != nil || ok { + t.Fatalf("row after re-run = found %v err %v, want gone", ok, err) + } +} + +// A failed file removal is the one state that is not self-cleaning: the +// covers row must survive so a retry can finish the job, and the store +// returns the error rather than logging — each caller logs and carries on, +// so the failure has no user-facing surface. +func TestReclaimCoverFailedUnlinkKeepsRow(t *testing.T) { + store := newTestStore(t) + seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0) + if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/stuck.jpg", []byte("stuck-art"), "image/jpeg"); err != nil { + t.Fatalf("put cover: %v", err) + } + stuck := CoverAddressForBytes([]byte("stuck-art")) + if _, _, err := store.ReplaceSeriesCover("asura", "solo", "https://cdn.example/covers/other.jpg", []byte("other-art"), "image/jpeg"); err != nil { + t.Fatalf("replace cover: %v", err) + } + // Make the unlink fail: the sharded path becomes a non-empty directory, + // which os.Remove refuses. + shard := coverShardPath(t, store, stuck) + if err := os.Remove(shard); err != nil { + t.Fatalf("clear file: %v", err) + } + if err := os.Mkdir(shard, 0o755); err != nil { + t.Fatalf("replace file with dir: %v", err) + } + if err := os.WriteFile(filepath.Join(shard, "blob"), []byte("x"), 0o644); err != nil { + t.Fatalf("fill dir: %v", err) + } + + if err := store.ReclaimCover(stuck); err == nil { + t.Fatal("ReclaimCover over an unremovable file = nil, want the error") + } + var one int + if err := store.db.QueryRow(`SELECT 1 FROM covers WHERE address = $1`, stuck).Scan(&one); err != nil { + t.Fatal("covers row after failed unlink is gone; want it left for a retry") + } +} -- 2.52.0 From bc64a1d894819273c5f4e6de2db8fe7b4a76e8a2 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 09:55:01 +0700 Subject: [PATCH 8/8] backend: remove orphan series from the admin (ticket #155) (*Store).RemoveSeries deletes one series row by (site, series_id) via a plain parameterized DELETE; a bookmarks_series_fk violation outside 23503 is translated into the ErrSeriesHasBookmarks sentinel so no driver type escapes the store. The caller reads the row's cover address before the delete and reclaims it after: ReclaimCover's guard cannot pass while a series row still points at the address. POST /admin/series/{key}/remove answers the list row with the removed row's fragment plus the heading re-rendered out of band at the fresh count (HX-Reswap: delete removes the row through the same button that swaps the refusal back in), and navigates from the detail page to the No-Readers list (HX-Redirect for htmx, a 303 for plain clients). A removal that races a fresh Bookmark is a refusal, not a 500: the row re-renders at its new count with the fact spelled out. The control renders only at zero Reader count on both surfaces, gated by hx-confirm with the brief's copy. --- backend/internal/store/store.go | 32 +++ backend/internal/store/store_test.go | 109 ++++++++ backend/internal/web/admin.go | 1 + backend/internal/web/admin_series.go | 169 +++++++++++- backend/internal/web/admin_series_detail.go | 5 + backend/internal/web/static/admin.css | 7 + .../internal/web/templates/series-detail.html | 5 + .../internal/web/templates/series-list.html | 16 +- backend/web_test.go | 248 +++++++++++++++++- 9 files changed, 580 insertions(+), 12 deletions(-) diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index b403f6d..ba57a0c 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" + "github.com/jackc/pgx/v5/pgconn" _ "github.com/jackc/pgx/v5/stdlib" ) @@ -1082,6 +1083,37 @@ func (s *Store) Delete(readerID int64, key string) error { return nil } +// pgForeignKeyViolation is the SQLSTATE the driver surfaces when a Bookmark +// row refuses a Series delete (bookmarks_series_fk). pgconn exports no named +// constant for it, so the store names it here. +const pgForeignKeyViolation = "23503" + +// ErrSeriesHasBookmarks is RemoveSeries' refusal: a Reader still holds the +// Series, so the owner's removal must not reach past that record. The +// delete is the check — no NOT EXISTS pre-check that can race the insert — +// and the driver's foreign-key violation is translated here so no driver +// type escapes the store (issue #155). +var ErrSeriesHasBookmarks = errors.New("series has bookmarks") + +// RemoveSeries deletes one Series row by (site, series_id). It is refused +// while any Bookmark references the row; deleting an absent key is not an +// error, matching Delete. The caller owns the stranded Cover: read the row's +// cover_address before the delete and call ReclaimCover after it — the +// helper's guard cannot pass while the series row still points at the +// address, so the order is the sequence, not a preference. +func (s *Store) RemoveSeries(site, seriesID string) error { + if _, err := s.db.Exec( + `DELETE FROM series WHERE site = $1 AND series_id = $2`, + site, seriesID); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgForeignKeyViolation { + return ErrSeriesHasBookmarks + } + return fmt.Errorf("remove series %s:%s: %w", site, seriesID, err) + } + return nil +} + // RecordLanePass appends one pass and prunes every older row in the same // transaction. retainBefore is supplied by the poller's clock. func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error { diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index db286b9..4cb05c5 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -2340,3 +2340,112 @@ func TestReclaimCoverFailedUnlinkKeepsRow(t *testing.T) { t.Fatal("covers row after failed unlink is gone; want it left for a retry") } } + +// RemoveSeries is the orphan removal (#155): one Series, one delete, refused +// by the database while any Bookmark points at it. The store translates the +// foreign-key violation into its own sentinel so no driver type escapes, and +// the caller reaps the stranded Cover through ReclaimCover. +func TestRemoveSeriesRemovesOrphanAndReclaimsCover(t *testing.T) { + store := newTestStore(t) + seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0) + if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil { + t.Fatalf("orphan the series: %v", err) + } + if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/old.jpg", []byte("old-art"), "image/jpeg"); err != nil { + t.Fatalf("put cover: %v", err) + } + addr := CoverAddressForBytes([]byte("old-art")) + + if err := store.RemoveSeries("asura", "solo"); err != nil { + t.Fatalf("RemoveSeries: %v", err) + } + // The caller's sequence: the row is deleted first, then the address is + // reclaimed — the guard cannot pass while the row still points at it. + if err := store.ReclaimCover(addr); err != nil { + t.Fatalf("ReclaimCover: %v", err) + } + var one int + if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&one); err != sql.ErrNoRows { + t.Fatalf("series row after remove = %v, want sql.ErrNoRows", err) + } + if _, _, ok, err := store.CoverByAddress(addr); err != nil || ok { + t.Fatalf("covers row after remove = found %v err %v, want gone", ok, err) + } + if _, err := os.Stat(coverShardPath(t, store, addr)); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("sharded file after remove = %v, want fs.ErrNotExist", err) + } +} + +// The refusal is the whole point of the sentinel: a Series a Reader still +// holds is not removed, its row is untouched and its Cover keeps serving. +func TestRemoveSeriesRefusedWhileBookmarked(t *testing.T) { + store := newTestStore(t) + seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0) + if err := store.SetSeriesCover("asura", "solo", "https://cdn.example/covers/kept.jpg", []byte("kept-art"), "image/jpeg"); err != nil { + t.Fatalf("put cover: %v", err) + } + addr := CoverAddressForBytes([]byte("kept-art")) + + if err := store.RemoveSeries("asura", "solo"); !errors.Is(err, ErrSeriesHasBookmarks) { + t.Fatalf("RemoveSeries on a bookmarked series = %v, want ErrSeriesHasBookmarks", err) + } + var held int + if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "solo").Scan(&held); err != nil { + t.Fatal("series row after refusal is gone; want it untouched") + } + if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "kept-art" { + t.Fatalf("cover after refusal = found %v err %v, want still served", ok, err) + } +} + +// A Series sharing its Cover address with a second Series is removed while +// the artwork stays readable through CoverByAddress: the series row stops +// referencing the address first, so ReclaimCover's guard passes for this +// caller without touching the shared bytes (ADR-0014). +func TestRemoveSeriesSparesSharedCover(t *testing.T) { + store := newTestStore(t) + seedForCheck(t, store, "asura:solo", "https://asurascans.com/comics/solo", 0) + seedForCheck(t, store, "asura:second", "https://asurascans.com/comics/second", 0) + if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil { + t.Fatalf("orphan solo: %v", err) + } + if err := store.Delete(store.OwnerID(), "asura:second"); err != nil { + t.Fatalf("orphan second: %v", err) + } + const src = "https://cdn.example/covers/shared.jpg" + if err := store.SetSeriesCover("asura", "solo", src, []byte("shared-art"), "image/jpeg"); err != nil { + t.Fatalf("put cover on solo: %v", err) + } + if err := store.SetSeriesCover("asura", "second", src, []byte("shared-art"), "image/jpeg"); err != nil { + t.Fatalf("put cover on second: %v", err) + } + addr := CoverAddressForBytes([]byte("shared-art")) + + if err := store.RemoveSeries("asura", "solo"); err != nil { + t.Fatalf("RemoveSeries: %v", err) + } + // The guard spares the shared bytes even though this caller reclaims. + if err := store.ReclaimCover(addr); err != nil { + t.Fatalf("ReclaimCover over a shared address: %v", err) + } + if body, _, ok, err := store.CoverByAddress(addr); err != nil || !ok || string(body) != "shared-art" { + t.Fatalf("shared bytes after remove = found %v err %v, want still served", ok, err) + } + if _, err := os.Stat(coverShardPath(t, store, addr)); err != nil { + t.Fatalf("sharded file after remove: %v, want present", err) + } + var one int + if err := store.db.QueryRow(`SELECT 1 FROM series WHERE site = $1 AND series_id = $2`, "asura", "second").Scan(&one); err != nil { + t.Fatal("the second series row vanished with the first") + } +} + +// Deleting an absent key removes nothing and is not an error, matching the +// Delete precedent — the handler's own lookups turn the absent case into the +// 404 before the store ever sees it. +func TestRemoveSeriesMissingKeyIsCleanNoOp(t *testing.T) { + store := newTestStore(t) + if err := store.RemoveSeries("asura", "ghost"); err != nil { + t.Fatalf("RemoveSeries on a missing key = %v, want nil", err) + } +} diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index cad99c5..c3f6aff 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -49,6 +49,7 @@ func (h *Handler) adminRoutes() []adminRoute { {"POST /admin/series/{key}/poll", h.adminSeriesPoll}, {"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest}, {"POST /admin/series/{key}/series-url", h.adminSeriesSetURL}, + {"POST /admin/series/{key}/remove", h.adminSeriesRemove}, {"POST /admin/lanes/{site}/pause", h.adminLanePause}, {"POST /admin/lanes/{site}/resume", h.adminLaneResume}, {"GET /ui/admin/lanes", h.uiLanes}, diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index d94c255..e0cfc35 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -1,6 +1,7 @@ package web import ( + "errors" "fmt" "log" "math" @@ -48,7 +49,6 @@ var seriesFilterOrder = []string{ } // seriesListView is the Series list page's data. The template renders strings -// and flags, and every judgement about what a value means is made here. type seriesListView struct { Filters []seriesFilterOption Sites []string @@ -57,6 +57,9 @@ type seriesListView struct { FilterLabel string Rows []seriesRowView Total int + // OOB marks the out-of-band copy of the heading the removal answer + // carries; on the page itself it is false (issue #155). + OOB bool // KindBoth / KindManga / KindNovel are the Library segment links, and // PrevHref / NextHref the pager's, all carrying the active filter, Site // and Kind so narrowing never drops state. @@ -101,6 +104,12 @@ type seriesRowView struct { CanPoll bool Pending bool Requested string // "requested 3m ago", rendered only while pending + // CanRemove is the Remove control's visibility: only a Series no Reader + // holds can be removed, so the owner is never offered a button that the + // database will always refuse (issue #155). RemovalRefused marks the one + // raced answer: the row stays and says a fresh Bookmark caught the press. + CanRemove bool + RemovalRefused bool } // adminSeries renders the filterable, bookmarkable Series list: filter, Site, @@ -279,6 +288,163 @@ func (h *Handler) adminSeriesSetURL(w http.ResponseWriter, r *http.Request) { h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a)) } +// seriesListHeadView is the list heading's data. The template renders it +// inline at the top of the Series list and out of band in the removal answer +// (OOB true, like the chrome partials' OOB flag): the count and the filter +// label are one fact (issue #155). +type seriesListHeadView struct { + Total int + FilterLabel string + OOB bool +} + +// adminSeriesRemove is the orphan removal: one Series, one delete, refused by +// the database while any Bookmark exists (translated by the store, never a +// driver error on the page). The owner gate is the route's, not this +// handler's; the body is capped like the API path caps its bodies; the key is +// validated here — a malformed key is a 400 and an unknown one a 404. +// +// The Cover is read from the row before the delete and reclaimed after it: +// ReclaimCover's guard cannot pass while a series row still points at the +// address, so the order is the sequence, not a preference. A reclamation +// failure is not a removal failure — the row is gone and the covers row +// survives for a retry; the handler logs and answers success, because the +// failure has no user-facing surface. +// +// Two callers, one handler, branched on HX-Target like adminSeriesPoll. The +// detail page's remove answers with a navigation — to the No-Readers list on +// success, back to the detail page when a fresh Bookmark raced the press, +// where the new count is visible. The list row's answers with the removed +// row's fragment and the heading re-rendered with the fresh count out of +// band; HX-Reswap deletes the row through the same button that swaps the +// refusal back in, and the count query runs over the press's own filter +// state, so the heading describes the list the owner is looking at. +func (h *Handler) adminSeriesRemove(w http.ResponseWriter, r *http.Request) { + site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") + if !ok || site == "" || seriesID == "" { + http.Error(w, "bad series key", http.StatusBadRequest) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<16) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + // The row's Cover address is read before the delete because the delete is + // what makes it reclaimable. + a, found, err := h.adminSeriesByKey(site, seriesID) + if err != nil { + log.Printf("series remove %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + cover := a.CoverAddress + if err := h.store.RemoveSeries(site, seriesID); err != nil { + if errors.Is(err, store.ErrSeriesHasBookmarks) { + // A Bookmark landed between the owner's read and the press: the + // row stays, answered at its new count with the fact spelled + // out — never a 500, and never a deleted row. + if r.Header.Get("HX-Target") == "detail-meta" { + seriesRemoveNavigation(w, r, "/admin/series/"+site+":"+seriesID) + return + } + fresh, found, err := h.adminSeriesByKey(site, seriesID) + if err != nil { + log.Printf("series remove %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !found { + // A second press removed it while this one was refused; the + // row has nothing left to say. + http.NotFound(w, r) + return + } + band := 0 + if r.PostFormValue("band") == "1" { + band = 1 + } + row := seriesRow(fresh, band, time.Now()) + row.RemovalRefused = true + h.render(w, http.StatusOK, "series-row", row) + return + } + log.Printf("series remove %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := h.store.ReclaimCover(cover); err != nil { + log.Printf("series remove %s: reclaim cover: %v", site+":"+seriesID, err) + } + if r.Header.Get("HX-Target") == "detail-meta" { + seriesRemoveNavigation(w, r, "/admin/series?filter="+store.SeriesFilterNoReaders) + return + } + // The list answer: the removed row's fragment, plus the heading + // re-rendered with the fresh count. HX-Reswap deletes the row through the + // same button that swaps the refusal back in. The count query failing + // does not undo the removal — log it and answer the row alone. + w.Header().Set("HX-Reswap", "delete") + band := 0 + if r.PostFormValue("band") == "1" { + band = 1 + } + h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now())) + if head, err := h.seriesListHeadView(r); err != nil { + log.Printf("series remove %s: %v", site+":"+seriesID, err) + } else { + h.render(w, http.StatusOK, "series-list-head", head) + } +} + +// seriesListHeadView is the list heading with the count as it stands after a +// removal: the same filter, Site and Kind the press's row carried (the list +// row's button hx-includes the filterbar), so the figure describes the list +// the owner is looking at — the All filter and an unknown one stay the +// absent case. The count is the store's window total, one query. +func (h *Handler) seriesListHeadView(r *http.Request) (seriesListHeadView, error) { + filter := r.PostFormValue("filter") + if _, ok := seriesFilterLabels[filter]; !ok { + filter = store.SeriesFilterAll + } + site := r.PostFormValue("site") + kind := r.PostFormValue("kind") + if kind != store.KindManga && kind != store.KindNovel { + kind = "" + } + data, err := h.store.SeriesPage(store.SeriesFilter{ + Site: site, + Kind: kind, + Name: filter, + Cutoff: time.Now().Add(-ownerWindow).UnixMilli(), + Page: 1, + }) + if err != nil { + return seriesListHeadView{}, err + } + return seriesListHeadView{ + Total: data.Total, + FilterLabel: seriesFilterLabels[filter], + OOB: true, + }, nil +} + +// seriesRemoveNavigation answers a removal from the detail page. htmx gets a +// full navigation (HX-Redirect): a bare 303 would be followed by the request +// and the landing page swapped into the press's target, so the header is the +// redirect htmx can see; plain clients get the 303 the ticket names. +func seriesRemoveNavigation(w http.ResponseWriter, r *http.Request, to string) { + if r.Header.Get("HX-Request") != "" { + w.Header().Set("HX-Redirect", to) + return + } + http.Redirect(w, r, to, http.StatusSeeOther) +} + // seriesListView assembles one Series list view from the request's query // string. An unknown filter value is the absent All case, never an error: the // select's options are not the only way this URL can be reached. @@ -403,6 +569,7 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { Readers: a.ReaderCount, Band: i%2 == 1, CanPoll: canPoll, + CanRemove: a.ReaderCount == 0, Pending: pending, Requested: requested, } diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 4166f9a..2f5e889 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -52,6 +52,10 @@ type seriesDetailView struct { CanPoll bool Pending bool Requested string + // CanRemove is the Remove control's visibility (issue #155): only a + // Series no Reader holds can be removed, so the owner is never offered a + // button the database will always refuse. + CanRemove bool } // adminSeriesDetail renders one Series' page, keyed by the composite @@ -122,6 +126,7 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { Orphan: a.ReaderCount == 0, SightingRaised: a.RaisedByReader, CanPoll: canPoll, + CanRemove: a.ReaderCount == 0, Pending: pending, Requested: requested, } diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index dd4f23e..6eabb79 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -653,6 +653,13 @@ margin-left: auto; } +.admin-sheet .tbl.series .row-msg { + grid-column: 1 / -1; + margin-top: 6px; + color: var(--danger-soft); + font: 400 13px/1.4 var(--font-body); +} + .admin-sheet .detail-back { display: inline-block; margin: 18px 0 0; diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html index 71f63c8..1bc2ba5 100644 --- a/backend/internal/web/templates/series-detail.html +++ b/backend/internal/web/templates/series-detail.html @@ -34,6 +34,11 @@
{{end}} +{{if .CanRemove}} +
+
+
+{{end}} {{end}} {{/* series-detail-meta is the meta line, and the answer a Check now or diff --git a/backend/internal/web/templates/series-list.html b/backend/internal/web/templates/series-list.html index c2cce35..0d51ff2 100644 --- a/backend/internal/web/templates/series-list.html +++ b/backend/internal/web/templates/series-list.html @@ -4,7 +4,7 @@ that can be bookmarked: the two selects submit the GET form, and the Library segment links and the pager preserve the filter and Site. */}} {{define "series-list"}} -
+