Files
mangaBookmark/backend/internal/web/admin_series_detail.go
T

169 lines
6.2 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
}
// 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,
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))
}