773 lines
28 KiB
Go
773 lines
28 KiB
Go
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 Series filter for the 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",
|
||
store.SeriesFilterFinished: "Finished",
|
||
}
|
||
|
||
// seriesFilterOrder is the select's render order: All first, then the
|
||
// permanent repairs, then the fixable ones (issue #140). Finished rides the
|
||
// tail, last — deliberate, not a repair — and the Overview's stats block
|
||
// renders the same tail, which is what sits the finished figure last there.
|
||
var seriesFilterOrder = []string{
|
||
store.SeriesFilterAll,
|
||
store.SeriesFilterNoURL,
|
||
store.SeriesFilterNoChapter,
|
||
store.SeriesFilterNoReaders,
|
||
store.SeriesFilterNeverChecked,
|
||
store.SeriesFilterStale,
|
||
store.SeriesFilterNoCover,
|
||
store.SeriesFilterReaderReport,
|
||
store.SeriesFilterFinished,
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// A page past the end is not an empty list: the store's window count runs
|
||
// over the rows the result set carries, so an overflow page reports zero
|
||
// 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 filter with its library-wide count, one
|
||
// SeriesShapes pass per filter summed in Go — the shipped surface offers nine
|
||
// 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()
|
||
}
|