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.SeriesFilterFailing: "Failing over 12h", store.SeriesFilterUnverified: "Unverified Reader number", store.SeriesFilterFinished: "Finished", store.SeriesFilterSiteCompleted: "Site says completed", } // seriesFilterOrder is the select's render order: All first, then the // permanent repairs, then the fixable ones (issue #140). Finished and the // site-completed hint ride the tail — deliberate, not repairs — and the // Overview's stats block renders the same tail, which is what sits the // finished and site-completed figures last there. var seriesFilterOrder = []string{ store.SeriesFilterAll, store.SeriesFilterNoURL, store.SeriesFilterNoChapter, store.SeriesFilterNoReaders, store.SeriesFilterNeverChecked, store.SeriesFilterStale, store.SeriesFilterNoCover, store.SeriesFilterReaderReport, store.SeriesFilterFailing, store.SeriesFilterUnverified, store.SeriesFilterFinished, store.SeriesFilterSiteCompleted, } // 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 // Failure names the standing failure and how long it has stood — "not // found · 3d ago", "" while no failure row stands. Its own field, never a // Notes chip: the chips cap at two plus a tail, so the one fact that // names the failure would be the most likely to be truncated away. Failure string } // 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 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) if a.FailureOutcome != "" { row.Failure = outcomeWord(a.FailureOutcome) + " · " + checkedAge(now, a.FailingSince) } 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 } // outcomeWord spells the wire failure word as the Lanes chips spell it — a // space, not the underscore: not_found reads "not found", no_chapter "no // chapter". The remaining words are their own spelling, so an unknown word // degrades to itself rather than vanishing from the page. func outcomeWord(wire string) string { switch wire { case "not_found": return "not found" case "no_chapter": return "no chapter" } return wire } // 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() }