Implements spec #135 (spec 2 of 4, derived from wayfinder map #114; decisions settled in #120/#121/#125/#131). Blocked-by #134 is merged, so this lands on `main`. Four owner actions the dashboard can now perform, one ticket each: - **#149** — Latest Chapter correction: one numeric input, overwritten by the next machine write. - **#151** — Series URL repair: owner-typed, gated by the poller's own fetch gate. - **#150 / #153 / #154** — Cover replacement: addresses derived from bytes (`#150`), a Forced Poll replaces the Cover while an ordinary pass still only fills a blank one (`#153`), and byte reclamation is one guarded helper, file first / covers row last (`#154`). - **#155** — Orphan removal: one Series at a time, with the foreign key as the guard. Plus **#152** — Latest Chapter provenance: one derived line naming the actor class, so an owner can tell a hand-edited number from a machine read. - Migration `0015_latest_correction.sql` adds the correction/provenance columns; `0009` now derives cover addresses from bytes. - ADR `0014-cover-addresses-from-bytes.md` records the address scheme. Backend tests cover the store, poller, admin handlers, and web routes (`go test ./...`, needs Docker). Reviewed-on: #156 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #156.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -47,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
|
||||
@@ -56,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.
|
||||
@@ -100,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,
|
||||
@@ -172,6 +182,269 @@ 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))
|
||||
}
|
||||
|
||||
// adminSeriesSetURL is the series URL repair: the owner types one address
|
||||
// and the Series' Poll fetches it from then on, verified by the same gate
|
||||
// the poller uses before it fetches anything — a URL failing
|
||||
// latest.FetchableSeriesURL answers 400 and never reaches the store. The
|
||||
// repair is a store, not a verification: it performs no outbound fetch, and
|
||||
// the owner presses Check now afterwards. This lifts the write-once rule of
|
||||
// Series.SeriesURL for the owner only — a Reader's PUT is still ignored. 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.
|
||||
func (h *Handler) adminSeriesSetURL(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
|
||||
}
|
||||
seriesURL := r.PostFormValue("series_url")
|
||||
if !latest.FetchableSeriesURL(site, seriesURL) {
|
||||
http.Error(w, "series URL must be an https address on this site's host", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
log.Printf("series url %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.SetSeriesURL(site, seriesID, seriesURL); err != nil {
|
||||
log.Printf("series url %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, like the correction's answer does.
|
||||
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||
if err != nil {
|
||||
log.Printf("series url %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))
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -296,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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user