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

767 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"errors"
"fmt"
"log"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/store"
)
// seriesPageSize matches the store's row-read page length: the pager's range
// must agree with the LIMIT the store applies or the "of N" figure describes
// the wrong page. The store does not export it (#140).
const seriesPageSize = 50
// seriesFilterLabels names every hygiene filter for the Series list select,
// keyed by the wire constant the URL carries. The render order is
// seriesFilterOrder; the labels are read by later admin tickets too, so the
// map and the constants cannot drift apart.
var seriesFilterLabels = map[string]string{
store.SeriesFilterAll: "All series",
store.SeriesFilterNoURL: "No series URL",
store.SeriesFilterNoChapter: "Never read a chapter",
store.SeriesFilterNoReaders: "No Readers",
store.SeriesFilterNeverChecked: "Never checked",
store.SeriesFilterStale: "Not checked in 12h",
store.SeriesFilterNoCover: "No cover",
store.SeriesFilterReaderReport: "Latest from a Reader",
}
// seriesFilterOrder is the select's render order: All first, then the
// permanent repairs, then the fixable ones (issue #140).
var seriesFilterOrder = []string{
store.SeriesFilterAll,
store.SeriesFilterNoURL,
store.SeriesFilterNoChapter,
store.SeriesFilterNoReaders,
store.SeriesFilterNeverChecked,
store.SeriesFilterStale,
store.SeriesFilterNoCover,
store.SeriesFilterReaderReport,
}
// seriesListView is the Series list page's data. The template renders strings
type seriesListView struct {
Filters []seriesFilterOption
Sites []string
Site string // "" = every Site
Kind string // "" = both libraries
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.
KindBoth string
KindManga string
KindNovel string
PrevHref string
NextHref string
Range string
}
// seriesFilterOption is one entry of the Show select: its wire value, its
// rendered label with the library-wide count, and whether it is the active
// filter.
type seriesFilterOption struct {
Name string
Label string
Count int
Selected bool
}
// seriesRowView is one Series row formatted for the template. Band carries
// the alternating row tint by class rather than nth-of-type, so the confirm
// rows later tickets add are row siblings without breaking the alternation.
// Attention tints the title patina: a row with any hygiene chip needs one.
//
// CanPoll is the Check now control's visibility: absent on a Series with no
// page to fetch and on an orphan, so the owner is never offered a button that
// can never do anything. Pending is derived — the request stamp is newer than
// the check stamp — and Requested is its ageing label.
type seriesRowView struct {
Key string
Title string
Site string
Ch string // chapter number; "—" until first captured
Age string // checked age; "never" until first check
Readers int
Notes []string // chips, capped at two
More int // chips past the cap, rendered as a +N tail
Band bool
Attention bool
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
// Finished is the row's display of the owner's finish stamp: the list row
// shows the state and never offers the control — that lives on the detail
// page, where a press that retires a Series from the Lane is on purpose
// and confirm-gated (issue #158).
Finished bool
}
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
// Library and page all live in the query string, so the list's state is an
// address rather than a click path.
func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) {
view, err := h.seriesListView(r)
if err != nil {
log.Printf("admin series: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderAdmin(w, adminView{Page: "series", SeriesList: view})
}
// adminSeriesPoll is the Check now action: it stamps the Series' force_poll_at
// and answers with the freshly rendered row, so the figures describe the
// state after the press. The control never commands the poller — the request
// is a fact about the Series, and the Lane's next pass reads it through
// DueForLatestCheck (ADR-0013). 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) adminSeriesPoll(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
}
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
log.Printf("series poll %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.ForceSeriesPoll(site, seriesID, time.Now().UnixMilli()); err != nil {
log.Printf("series poll %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Re-read after the stamp: the answer must describe the state after the
// press. The detail page's control swaps its meta in place and the list
// row's swaps the row; htmx names an id target in HX-Target, so the
// response matches the surface it came from. The row's band parity travels
// with the press (hx-vals), so the swap keeps the zebra alternation.
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series poll %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
if r.Header.Get("HX-Target") == "detail-meta" {
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
return
}
band := 0
if r.PostFormValue("band") == "1" {
band = 1
}
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
}
// adminSeriesFinish is the owner's Finish control: it stamps the Series'
// finished_at and answers with the freshly rendered meta fragment, so the
// "finished <age> ago" line describes the state after the press. The Lane's
// next pass reads the stamp and stops polling the Series (issue #157). 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) adminSeriesFinish(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
}
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
log.Printf("series finish %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.SetSeriesFinished(site, seriesID, time.Now().UnixMilli()); err != nil {
log.Printf("series finish %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Re-read after the stamp: the answer must describe the state after the
// press, so the line reads "finished just now". The control's one caller
// is the detail page, which swaps the meta fragment in place.
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series finish %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))
}
// adminSeriesUnfinish is the reversal of the Finish control: it clears the
// stamp (writes zero) and answers with the freshly rendered meta fragment, so
// the Series is back in the Lane's queue from its next pass. Reversal, so it
// fires instantly with no confirm row (issue #158).
func (h *Handler) adminSeriesUnfinish(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
}
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
log.Printf("series unfinish %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.SetSeriesFinished(site, seriesID, 0); err != nil {
log.Printf("series unfinish %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 fragment no longer carries the finished line.
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series unfinish %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))
}
// 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.
func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) {
q := r.URL.Query()
filter := q.Get("filter")
if _, ok := seriesFilterLabels[filter]; !ok {
filter = store.SeriesFilterAll
}
site := q.Get("site")
kind := q.Get("kind")
if kind != store.KindManga && kind != store.KindNovel {
kind = ""
}
page := 1
if p, err := strconv.Atoi(q.Get("page")); err == nil && p > 1 {
page = p
}
sf := store.SeriesFilter{
Site: site,
Kind: kind,
Name: filter,
Cutoff: time.Now().Add(-ownerWindow).UnixMilli(),
Page: page,
}
data, err := h.store.SeriesPage(sf)
if err != nil {
return seriesListView{}, err
}
// rows and zero total, and the list re-reads at page 1 to know the truth.
if len(data.Rows) == 0 && page > 1 {
page = 1
sf.Page = 1
data, err = h.store.SeriesPage(sf)
if err != nil {
return seriesListView{}, err
}
}
view := seriesListView{
Site: site,
Kind: kind,
FilterLabel: seriesFilterLabels[filter],
Rows: make([]seriesRowView, 0, len(data.Rows)),
Total: data.Total,
Sites: latest.SiteNames(),
}
now := time.Now()
for i, a := range data.Rows {
view.Rows = append(view.Rows, seriesRow(a, i, now))
}
view.Filters, err = h.seriesFilterOptions(filter, sf.Cutoff)
if err != nil {
return seriesListView{}, err
}
view.KindBoth = seriesListHref(filter, site, "", 0)
view.KindManga = seriesListHref(filter, site, store.KindManga, 0)
view.KindNovel = seriesListHref(filter, site, store.KindNovel, 0)
if page > 1 {
view.PrevHref = seriesListHref(filter, site, kind, page-1)
}
if last := (data.Total + seriesPageSize - 1) / seriesPageSize; page < last {
view.NextHref = seriesListHref(filter, site, kind, page+1)
}
view.Range = pagerRange(data.Total, len(data.Rows), page)
return view, nil
}
// seriesFilterOptions renders every hygiene filter with its library-wide
// count, one SeriesShapes pass per filter summed in Go — the shipped surface
// offers eight grouped passes, not a single stats query (#140). The counts
// are library-wide because the select sits next to the Site narrowing and
// must not shift as the owner narrows the list itself. Cutoff travels with
// the stale filter, or its count would always be zero.
func (h *Handler) seriesFilterOptions(selected string, cutoff int64) ([]seriesFilterOption, error) {
out := make([]seriesFilterOption, 0, len(seriesFilterOrder))
for _, name := range seriesFilterOrder {
shapes, err := h.store.SeriesShapes(store.SeriesFilter{Name: name, Cutoff: cutoff})
if err != nil {
return nil, err
}
count := 0
for _, sh := range shapes {
count += sh.Total
}
out = append(out, seriesFilterOption{
Name: name,
Label: seriesFilterLabels[name],
Count: count,
Selected: name == selected,
})
}
return out, nil
}
// seriesRow shapes one store row for the template, capping its chips at two
// plus a +N tail; attention marks a row that carries any.
// pollState derives the Check now control and the pending marker (issue
// #146), shared by the list row and the detail page: CanPoll is false on a
// Series with no page to fetch and on an orphan, so the owner is never
// offered a button that can never do anything. Pending is derived — the
// request stamp is newer than the check stamp — and requested is its ageing
// label, which never expires.
func pollState(a store.AdminSeries, now time.Time) (canPoll, pending bool, requested string) {
canPoll = a.SeriesURL != "" && a.ReaderCount > 0
if a.ForcePollAt > a.LatestCheckedAt {
pending = true
requested = requestedAge(now, a.ForcePollAt)
}
return canPoll, pending, requested
}
func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
canPoll, pending, requested := pollState(a, now)
row := seriesRowView{
Key: a.Key(),
Site: a.Site,
Title: a.Title,
Readers: a.ReaderCount,
Band: i%2 == 1,
CanPoll: canPoll,
CanRemove: a.ReaderCount == 0,
Pending: pending,
Requested: requested,
Finished: a.FinishedAt > 0,
}
if a.LatestChapterNum != nil {
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
} else {
row.Ch = "—"
}
row.Age = checkedAge(now, a.LatestCheckedAt)
notes := seriesNotes(a, now)
if n := len(notes); n > 2 {
row.Notes, row.More = notes[:2], n-2
} else {
row.Notes = notes
}
row.Attention = len(notes) > 0
return row
}
// seriesNotes are a row's hygiene chips in the design's order: no URL, no
// cover, orphan, stale, reader sighting.
func seriesNotes(a store.AdminSeries, now time.Time) []string {
notes := []string{}
if a.SeriesURL == "" {
notes = append(notes, "no URL")
}
if a.CoverAddress == "" {
notes = append(notes, "no cover")
}
if a.ReaderCount == 0 {
notes = append(notes, "orphan")
}
if a.LatestCheckedAt > 0 && a.LatestCheckedAt < now.Add(-ownerWindow).UnixMilli() {
notes = append(notes, "stale")
}
if a.RaisedByReader {
notes = append(notes, "reader sighting")
}
return notes
}
// checkedAge formats how long ago a Series was last checked, at the
// granularity the list reads at — minutes, hours, days. Zero means never.
func checkedAge(now time.Time, ts int64) string {
if ts == 0 {
return "never"
}
d := now.Sub(time.UnixMilli(ts))
switch {
case d < time.Hour:
m := int(d / time.Minute)
if m < 1 {
m = 1
}
return fmt.Sprintf("%dm ago", m)
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d/time.Hour))
default:
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
}
}
// requestedAge is the pending marker's text: how long ago the owner asked,
// and nothing about when the request will run — the page does not know when a
// sleeping browser will wake (issue #146). An unanswered request ages forever;
// there is no expiry.
func requestedAge(now time.Time, ts int64) string {
return "requested " + checkedAge(now, ts)
}
// pagerRange is the pager's "1–50 of 120" line. The template renders the
// pager only over rows (the empty state replaces it), so it is never asked
// to describe an empty list.
func pagerRange(total, rows, page int) string {
from := (page-1)*seriesPageSize + 1
return fmt.Sprintf("%d–%d of %d", from, from+rows-1, total)
}
// seriesListHref is one Series list address carrying the filter, Site, Kind
// and page. The All filter and page 1 are the absent cases and stay out of
// the URL, so the default address is the shortest one.
func seriesListHref(filter, site, kind string, page int) string {
q := url.Values{}
if filter != "" && filter != store.SeriesFilterAll {
q.Set("filter", filter)
}
if site != "" {
q.Set("site", site)
}
if kind != "" {
q.Set("kind", kind)
}
if page > 1 {
q.Set("page", strconv.Itoa(page))
}
if len(q) == 0 {
return "/admin/series"
}
return "/admin/series?" + q.Encode()
}