bc64a1d894
(*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.
174 lines
6.4 KiB
Go
174 lines
6.4 KiB
Go
package web
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// seriesDetailView is one Series' page as the owner sees it: strings and
|
|
// flags, every judgement made here, the template left to print. ReaderCount
|
|
// is the only figure that crosses the privacy boundary — the owner learns how
|
|
// many Readers hold the Series, never which Reader reads what.
|
|
type seriesDetailView struct {
|
|
Key string // "<site>:<series_id>", the page's address and the Series' identity
|
|
Site string
|
|
Kind string
|
|
// Title, Cover and Chapter come from the shared Series row; the Cover is
|
|
// the wire URL of the stored bytes, "" before any exist.
|
|
Title string
|
|
Cover string
|
|
Chapter string // Latest Chapter number, or "—" before the first capture
|
|
Checked string // how long ago the poller last checked, or "never"
|
|
// URL is the stored source address, prefilled into the repair input —
|
|
// the one stored string this page renders back into a form (issue #151).
|
|
URL string
|
|
Readers int
|
|
// Corrected is the correction marker's text, "" while no Correction
|
|
// stands: "corrected <age> 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
|
|
|
|
// 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
|
|
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 —
|
|
// the request stamp is newer than the check stamp — and Requested is its
|
|
// ageing label.
|
|
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
|
|
// "<site>:<series_id>" the list row already shows. The row is read through
|
|
// the list's own SeriesPage read narrowed to the key's Site: the admin
|
|
// projection is the privacy boundary, and a dedicated single-row read would
|
|
// be a second definition of it.
|
|
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
|
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
|
if !ok || site == "" || seriesID == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
a, found, err := h.adminSeriesByKey(site, seriesID)
|
|
if err != nil {
|
|
log.Printf("series detail %s: %v", site+":"+seriesID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !found {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(a)})
|
|
}
|
|
|
|
// adminSeriesByKey reads one Series through the list's own SeriesPage read
|
|
// narrowed to the key's Site: the admin projection is the privacy boundary,
|
|
// and a dedicated single-row read would be a second definition of it. Absence
|
|
// is reported with found=false, never an error.
|
|
// ponytail: a page scan per keyed read, one query per page of the Site's rows
|
|
// up to the window total; a keyed read alongside SeriesPage when the library
|
|
// outgrows the page size.
|
|
func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bool, error) {
|
|
seen := 0
|
|
for page := 1; ; page++ {
|
|
p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page})
|
|
if err != nil {
|
|
return store.AdminSeries{}, false, err
|
|
}
|
|
seen += len(p.Rows)
|
|
for i := range p.Rows {
|
|
if p.Rows[i].SeriesID == seriesID {
|
|
return p.Rows[i], true, nil
|
|
}
|
|
}
|
|
if seen >= p.Total {
|
|
break
|
|
}
|
|
}
|
|
return store.AdminSeries{}, false, nil
|
|
}
|
|
|
|
// seriesDetailView shapes one AdminSeries row for display: every judgement in
|
|
// Go, the template left to print strings and flags.
|
|
func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
|
canPoll, pending, requested := pollState(a, time.Now())
|
|
v := seriesDetailView{
|
|
Key: a.Key(),
|
|
Site: a.Site,
|
|
Kind: a.Kind,
|
|
Title: a.Title,
|
|
Cover: h.store.CoverWireURL(a.CoverAddress),
|
|
URL: a.SeriesURL,
|
|
Readers: a.ReaderCount,
|
|
Unpollable: a.SeriesURL == "",
|
|
NoCover: a.CoverAddress == "",
|
|
Orphan: a.ReaderCount == 0,
|
|
SightingRaised: a.RaisedByReader,
|
|
CanPoll: canPoll,
|
|
CanRemove: a.ReaderCount == 0,
|
|
Pending: pending,
|
|
Requested: requested,
|
|
}
|
|
if a.LatestChapterNum == nil {
|
|
v.Chapter = "—"
|
|
} else {
|
|
v.Chapter = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
|
}
|
|
if a.LatestCheckedAt == 0 {
|
|
v.Checked = "never"
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// correctedAge is the correction marker's text: "corrected <age> 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))
|
|
}
|