Admin dashboard: pages, Lane observability, poll pass log, per-Series intervention (#134) #148
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -173,14 +172,7 @@ func fetcherFor(site string, browser, tls Fetcher) Fetcher {
|
|||||||
// laneNames returns every registry Site in the deterministic order both Run
|
// laneNames returns every registry Site in the deterministic order both Run
|
||||||
// and runOnce iterate: sorted, so lane behaviour and its tests agree on who
|
// and runOnce iterate: sorted, so lane behaviour and its tests agree on who
|
||||||
// runs first.
|
// runs first.
|
||||||
func laneNames() []string {
|
func laneNames() []string { return SiteNames() }
|
||||||
names := make([]string, 0, len(sites))
|
|
||||||
for name := range sites {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Poller) Run(ctx context.Context) {
|
func (p *Poller) Run(ctx context.Context) {
|
||||||
names := laneNames()
|
names := laneNames()
|
||||||
|
|||||||
@@ -511,6 +511,18 @@ var sites = map[string]site{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteNames returns every registry Site, sorted. The admin Series list's Site
|
||||||
|
// select needs the full registry, not just the Sites that have rows, and
|
||||||
|
// laneNames() is the poller's copy of the same list — both read this.
|
||||||
|
func SiteNames() []string {
|
||||||
|
names := make([]string, 0, len(sites))
|
||||||
|
for name := range sites {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
// browserBackedSites is derived from the registry: the Sites whose pages are
|
// browserBackedSites is derived from the registry: the Sites whose pages are
|
||||||
// read through the browser sidecar. Sorted so callers that range it (the
|
// read through the browser sidecar. Sorted so callers that range it (the
|
||||||
// browser fetcher's dispatch) see a stable order instead of map-iteration
|
// browser fetcher's dispatch) see a stable order instead of map-iteration
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type LaneReporter interface {
|
|||||||
LaneStatus() latest.Status
|
LaneStatus() latest.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownerWindow is the staleness boundary the Series list's "not checked in
|
||||||
|
// 12h" filter compares against. Declared once; later admin tickets read it.
|
||||||
|
const ownerWindow = 12 * time.Hour
|
||||||
|
|
||||||
// adminView is the shared shell data for an administrative page and the roster
|
// adminView is the shared shell data for an administrative page and the roster
|
||||||
// fragment returned after a Reader action.
|
// fragment returned after a Reader action.
|
||||||
type adminView struct {
|
type adminView struct {
|
||||||
@@ -25,8 +29,9 @@ type adminView struct {
|
|||||||
Readers []store.ReaderSummary
|
Readers []store.ReaderSummary
|
||||||
// OwnerID travels with the roster so it can tell the owner's own row from
|
// OwnerID travels with the roster so it can tell the owner's own row from
|
||||||
// the Readers they may act on.
|
// the Readers they may act on.
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
Lanes lanesView
|
Lanes lanesView
|
||||||
|
SeriesList seriesListView
|
||||||
}
|
}
|
||||||
|
|
||||||
// lanesView is the Lane status block: one row per Site that has run, plus the
|
// lanesView is the Lane status block: one row per Site that has run, plus the
|
||||||
@@ -142,12 +147,6 @@ func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()})
|
h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminSeries renders the Series shell. Its data arrives in a later admin
|
|
||||||
// ticket.
|
|
||||||
func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.renderAdmin(w, adminView{Page: "series"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) {
|
func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) {
|
||||||
h.render(w, http.StatusOK, "admin", view)
|
h.render(w, http.StatusOK, "admin", view)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"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
|
||||||
|
// and flags, and every judgement about what a value means is made here.
|
||||||
|
type seriesListView struct {
|
||||||
|
Filters []seriesFilterOption
|
||||||
|
Sites []string
|
||||||
|
Site string // "" = every Site
|
||||||
|
Kind string // "" = both libraries
|
||||||
|
FilterLabel string
|
||||||
|
Rows []seriesRowView
|
||||||
|
Total int
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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.
|
||||||
|
func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||||
|
row := seriesRowView{
|
||||||
|
Key: a.Key(),
|
||||||
|
Title: a.Title,
|
||||||
|
Site: a.Site,
|
||||||
|
Readers: a.ReaderCount,
|
||||||
|
Band: i%2 == 1,
|
||||||
|
}
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
@@ -240,12 +240,20 @@
|
|||||||
color: var(--patina);
|
color: var(--patina);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .trow.attention .c-title {
|
||||||
|
color: var(--patina);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .tbl .c-ch,
|
.admin-sheet .tbl .c-ch,
|
||||||
.admin-sheet .tbl .c-rd {
|
.admin-sheet .tbl .c-rd {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: 26px;
|
padding-right: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .trow .c-note .mark {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .tbl .c-act {
|
.admin-sheet .tbl .c-act {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -368,6 +376,42 @@
|
|||||||
row-gap: 4px;
|
row-gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .thead > *:first-child {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl.series .thead > *:last-child {
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The site cell's colour is a class, never an inline style: site is
|
||||||
|
client-supplied and unvalidated, and a hostile value reaching a CSS
|
||||||
|
context would render ZgotmplZ, while an unknown class degrades to the
|
||||||
|
unstyled cell. */
|
||||||
|
.admin-sheet .tbl .c-site.site-asura {
|
||||||
|
color: var(--asura);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-demonic {
|
||||||
|
color: var(--demonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-comix {
|
||||||
|
color: var(--comix);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-kagane {
|
||||||
|
color: var(--kagane);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-novelfull {
|
||||||
|
color: var(--novelfull);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .tbl .c-site.site-lightnovelworld {
|
||||||
|
color: var(--lightnovelworld);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .tbl.series .trow {
|
.admin-sheet .tbl.series .trow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
@@ -434,6 +478,12 @@
|
|||||||
border-bottom-color: var(--patina);
|
border-bottom-color: var(--patina);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sheet .fsel select option,
|
||||||
|
.admin-sheet .pausebar select option {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .segrow {
|
.admin-sheet .segrow {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -480,6 +530,14 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sheet .pager .pg {
|
||||||
|
color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .pager .pg:hover {
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .pager .pg.disabled {
|
.admin-sheet .pager .pg.disabled {
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -497,6 +555,7 @@
|
|||||||
|
|
||||||
.admin-sheet .empty p {
|
.admin-sheet .empty p {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
|
font: 14px/1.5 var(--font-body);
|
||||||
color: var(--mute);
|
color: var(--mute);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
{{else if eq .Page "readers"}}
|
{{else if eq .Page "readers"}}
|
||||||
{{template "readers" .}}
|
{{template "readers" .}}
|
||||||
{{else if eq .Page "series"}}
|
{{else if eq .Page "series"}}
|
||||||
<h2 class="sec">Series</h2>
|
{{template "series-list" .SeriesList}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2 class="sec">Overview</h2>
|
<h2 class="sec">Overview</h2>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{{/* The Series list: every Series across every Reader's library, filtered by
|
||||||
|
one hygiene rule and narrowed by Site and Library. Filter, Site, Library
|
||||||
|
and page all live in the query string, so the list's state is an address
|
||||||
|
that can be bookmarked: the two selects submit the GET form, and the
|
||||||
|
Library segment links and the pager preserve the filter and Site. */}}
|
||||||
|
{{define "series-list"}}
|
||||||
|
<form class="filterbar" method="get" action="/admin/series">
|
||||||
|
<input type="hidden" name="kind" value="{{.Kind}}">
|
||||||
|
<label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()">
|
||||||
|
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
|
||||||
|
</select></label>
|
||||||
|
<label class="fsel"><span>Site</span><select name="site" onchange="this.form.submit()">
|
||||||
|
<option value=""{{if not .Site}} selected{{end}}>All sites</option>
|
||||||
|
{{range .Sites}}<option value="{{.}}"{{if eq $.Site .}} selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select></label>
|
||||||
|
<span class="segrow">
|
||||||
|
<a href="{{.KindBoth}}"{{if not .Kind}} class="active"{{end}}>both</a>
|
||||||
|
<a href="{{.KindManga}}"{{if eq .Kind "manga"}} class="active"{{end}}>manga</a>
|
||||||
|
<a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a>
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
<div class="listhead">{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
|
||||||
|
{{if .Rows}}
|
||||||
|
<div class="tbl series">
|
||||||
|
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
|
||||||
|
{{range .Rows}}
|
||||||
|
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}">
|
||||||
|
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a></span>
|
||||||
|
<span class="c-site site-{{.Site}}">{{.Site}}</span>
|
||||||
|
<span class="c-ch">{{.Ch}}</span>
|
||||||
|
<span>{{.Age}}</span>
|
||||||
|
<span class="c-rd">{{.Readers}}</span>
|
||||||
|
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}</span>
|
||||||
|
<span class="c-act"></span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="pager">
|
||||||
|
{{if .PrevHref}}<a class="pg" href="{{.PrevHref}}">‹ prev</a>{{else}}<span class="pg disabled">‹ prev</span>{{end}}
|
||||||
|
<span>{{.Range}}</span>
|
||||||
|
{{if .NextHref}}<a class="pg" href="{{.NextHref}}">next ›</a>{{else}}<span class="pg disabled">next ›</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty"><strong>No series</strong><p>Nothing matches <em>{{.FilterLabel}}</em>.</p></div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -1840,3 +1840,406 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
|||||||
t.Fatal("novel tab=new should render the novel All list")
|
t.Fatal("novel tab=new should render the novel All list")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seriesRowSeed is one series row (and optionally its bookmarks) for the
|
||||||
|
// Series list tests. Seeded with direct SQL because the store's own surface
|
||||||
|
// cannot produce an orphan series or a Reader-raised Latest Chapter — the
|
||||||
|
// same reason the store's admin tests seed this way.
|
||||||
|
type seriesRowSeed struct {
|
||||||
|
key string
|
||||||
|
kind string
|
||||||
|
url string
|
||||||
|
cover string // cover_address
|
||||||
|
checkedAt int64
|
||||||
|
latestNum *float64
|
||||||
|
bookmarks int // readers that hold it; 0 = orphan
|
||||||
|
raisedBy bool // a Reader's report is attributed as the raiser
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSeriesRow inserts one series row and its bookmarks (owner first, then
|
||||||
|
// fresh readers) with the exact admin-relevant facts a test needs.
|
||||||
|
func seedSeriesRow(t *testing.T, st *store.Store, db *sql.DB, seed seriesRowSeed) {
|
||||||
|
t.Helper()
|
||||||
|
site, seriesID, ok := strings.Cut(seed.key, ":")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("key %q: no ':' separator", seed.key)
|
||||||
|
}
|
||||||
|
if seed.kind == "" {
|
||||||
|
seed.kind = store.KindManga
|
||||||
|
}
|
||||||
|
var latestChapter any = ""
|
||||||
|
if seed.latestNum != nil {
|
||||||
|
latestChapter = "Chapter " + strconv.FormatFloat(*seed.latestNum, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
||||||
|
latest_checked_at, latest_chapter, latest_chapter_num)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
||||||
|
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
|
||||||
|
t.Fatalf("seed series %q: %v", seed.key, err)
|
||||||
|
}
|
||||||
|
for i := range seed.bookmarks {
|
||||||
|
var readerID int64 = st.OwnerID()
|
||||||
|
if i > 0 {
|
||||||
|
readerID = seedReader(t, st)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO bookmarks (reader_id, site, series_id,
|
||||||
|
last_chapter, last_chapter_num, last_chapter_url,
|
||||||
|
favorite, status, updated_at)
|
||||||
|
VALUES ($1, $2, $3, '', 0, '', false, 'reading', $4)`,
|
||||||
|
readerID, site, seriesID, seed.checkedAt); err != nil {
|
||||||
|
t.Fatalf("seed bookmark %q: %v", seed.key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seed.raisedBy {
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`UPDATE series SET latest_raised_by = $1 WHERE site = $2 AND series_id = $3`,
|
||||||
|
st.OwnerID(), site, seriesID); err != nil {
|
||||||
|
t.Fatalf("seed raised-by %q: %v", seed.key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedReader mints a fresh Reader for a second bookmark, so a series can carry
|
||||||
|
// a Reader count above one.
|
||||||
|
func seedReader(t *testing.T, st *store.Store) int64 {
|
||||||
|
t.Helper()
|
||||||
|
discordID := "seed-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
id, err := st.EnsureReader(discordID, [32]byte{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureReader: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSeriesPage drives one Series list request as the owner and returns the
|
||||||
|
// rendered body, failing the test on anything but a 200.
|
||||||
|
func adminSeriesPage(t *testing.T, srv http.Handler, st *store.Store, query string) string {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/series"+query, nil)
|
||||||
|
req.AddCookie(sessionCookie(t, st))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /admin/series%s status = %d, want 200", query, rr.Code)
|
||||||
|
}
|
||||||
|
return rr.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter select reaches the store as the wire constant and the heading
|
||||||
|
// states the same total the rows render: ?filter=no_cover renders only the
|
||||||
|
// no-cover row, its option label carries its library-wide count, and an
|
||||||
|
// unknown filter value is the absent All case, never an error.
|
||||||
|
func TestSeriesListFilterWiring(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:healthy", url: "https://asurascans.com/comics/healthy", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nocover", url: "https://asurascans.com/comics/nocover", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(3), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "https://asurascans.com/comics/stale", cover: "bbb", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(4), bookmarks: 1})
|
||||||
|
srv := newRouter(st, testConfig(), nil)
|
||||||
|
|
||||||
|
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
|
||||||
|
if !strings.Contains(body, "Title of asura:nocover") {
|
||||||
|
t.Errorf("no_cover list misses its row:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "Title of asura:healthy") || strings.Contains(body, "Title of asura:stale") {
|
||||||
|
t.Errorf("no_cover list renders a covered row:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "1 series") || !strings.Contains(body, "No cover") {
|
||||||
|
t.Errorf("no_cover heading lacks the filtered count and name:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "No cover (1)") {
|
||||||
|
t.Errorf("the filter option label lacks its count:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `<option value="no_cover" selected>`) {
|
||||||
|
t.Errorf("the no_cover option is not selected:\n%s", body)
|
||||||
|
}
|
||||||
|
// The stale option's count is cutoff-dependent: the row read passes the
|
||||||
|
// 12h boundary, and so must the aggregate that numbers the select.
|
||||||
|
if !strings.Contains(body, "Not checked in 12h (1)") {
|
||||||
|
t.Errorf("the stale option lacks its cutoff-dependent count:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown filter value is the absent All case: both rows, no error.
|
||||||
|
body = adminSeriesPage(t, srv, st, "?filter=bogus")
|
||||||
|
if !strings.Contains(body, "Title of asura:healthy") || !strings.Contains(body, "Title of asura:nocover") {
|
||||||
|
t.Errorf("unknown filter does not render All series:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `<option value="all" selected>`) {
|
||||||
|
t.Errorf("the all option is not selected for an unknown filter:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the
|
||||||
|
// kagane manga stale row renders, and every link the page emits carries the
|
||||||
|
// filter and Site so the narrowing survives in the URL.
|
||||||
|
func TestSeriesListSiteAndKindComposeWithFilter(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
stale := time.Now().Add(-24 * time.Hour).UnixMilli()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:want", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:novel", url: "u", cover: "c", kind: store.KindNovel, checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "kagane:fresh", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:stale", url: "u", cover: "c", checkedAt: stale, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
srv := newRouter(st, testConfig(), nil)
|
||||||
|
|
||||||
|
body := adminSeriesPage(t, srv, st, "?filter=stale&site=kagane&kind=manga")
|
||||||
|
if !strings.Contains(body, "Title of kagane:want") {
|
||||||
|
t.Errorf("stale+kagane+manga misses its row:\n%s", body)
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"Title of kagane:novel", "Title of kagane:fresh", "Title of asura:stale"} {
|
||||||
|
if strings.Contains(body, unwanted) {
|
||||||
|
t.Errorf("stale+kagane+manga renders %q:\n%s", unwanted, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "1 series") {
|
||||||
|
t.Errorf("heading lacks the narrowed count:\n%s", body)
|
||||||
|
}
|
||||||
|
// The narrowing survives in the URL: every emitted link carries the
|
||||||
|
// filter and Site, and the kind hidden input keeps it on select submits.
|
||||||
|
for _, want := range []string{
|
||||||
|
`href="/admin/series?filter=stale&site=kagane"`,
|
||||||
|
`href="/admin/series?filter=stale&kind=manga&site=kagane"`,
|
||||||
|
`href="/admin/series?filter=stale&kind=novel&site=kagane"`,
|
||||||
|
`<input type="hidden" name="kind" value="manga">`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("narrowing is lost from the URL; missing %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `<option value="stale" selected>`) {
|
||||||
|
t.Errorf("the stale filter is not kept selected:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `<option value="kagane" selected>`) {
|
||||||
|
t.Errorf("the kagane site is not kept selected:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// More than one page of rows: page 1 and page 2 share no key, the pager range
|
||||||
|
// comes from the window total (not a second query), and a page past the end
|
||||||
|
// re-reads at page 1 rather than rendering an empty table. All 55 rows share
|
||||||
|
// a zero check stamp, so only the (site, series_id) tie-break keeps the page
|
||||||
|
// boundary stable.
|
||||||
|
func TestSeriesListPagingIsStable(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO series (site, series_id, title, series_url, cover_address,
|
||||||
|
latest_checked_at)
|
||||||
|
SELECT 'asura', 'bulk-' || g, 'Bulk ' || g, 'https://asurascans.com/comics/bulk-' || g, 'c', 0
|
||||||
|
FROM generate_series(1, 55) AS g`); err != nil {
|
||||||
|
t.Fatalf("bulk seed series: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO bookmarks (reader_id, site, series_id, updated_at)
|
||||||
|
SELECT $1, 'asura', 'bulk-' || g, 1000
|
||||||
|
FROM generate_series(1, 55) AS g`, st.OwnerID()); err != nil {
|
||||||
|
t.Fatalf("bulk seed bookmarks: %v", err)
|
||||||
|
}
|
||||||
|
srv := newRouter(st, testConfig(), nil)
|
||||||
|
|
||||||
|
pageKeys := func(body string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
// parts[0] is the prelude before the first detail link; every later
|
||||||
|
// chunk starts with a key, so only those count.
|
||||||
|
for _, chunk := range strings.Split(body, `href="/admin/series/`)[1:] {
|
||||||
|
if i := strings.Index(chunk, `"`); i > 0 {
|
||||||
|
out[chunk[:i]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
p1 := adminSeriesPage(t, srv, st, "")
|
||||||
|
p2 := adminSeriesPage(t, srv, st, "?page=2")
|
||||||
|
if !strings.Contains(p1, "1–50 of 55") {
|
||||||
|
t.Errorf("page 1 pager range wrong:\n%s", p1)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p2, "51–55 of 55") {
|
||||||
|
t.Errorf("page 2 pager range wrong:\n%s", p2)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p1, `href="/admin/series?page=2"`) {
|
||||||
|
t.Errorf("page 1 lacks a next link:\n%s", p1)
|
||||||
|
}
|
||||||
|
k1, k2 := pageKeys(p1), pageKeys(p2)
|
||||||
|
if len(k1) != 50 || len(k2) != 5 {
|
||||||
|
t.Fatalf("pages hold %d and %d rows, want 50 and 5", len(k1), len(k2))
|
||||||
|
}
|
||||||
|
for k := range k1 {
|
||||||
|
if k2[k] {
|
||||||
|
t.Errorf("row %q repeats across pages", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(k1)+len(k2) != 55 {
|
||||||
|
t.Errorf("%d distinct rows across pages, want 55 (a row vanished)", len(k1)+len(k2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A page past the end re-reads at page 1: the pager states the first
|
||||||
|
// page's range and the rows render rather than an empty table.
|
||||||
|
pOver := adminSeriesPage(t, srv, st, "?page=99")
|
||||||
|
if !strings.Contains(pOver, "1–50 of 55") || len(pageKeys(pOver)) != 50 {
|
||||||
|
t.Errorf("a page past the end does not re-read at page 1:\n%s", pOver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page 2 of a one-page result re-reads at page 1: the store's window count
|
||||||
|
// only runs over the rows the result set carries, so an overflow page must
|
||||||
|
// not be rendered as an empty list.
|
||||||
|
func TestSeriesListPagePastEndReReadsAtPageOne(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:b", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:c", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
srv := newRouter(st, testConfig(), nil)
|
||||||
|
|
||||||
|
body := adminSeriesPage(t, srv, st, "?page=2")
|
||||||
|
if !strings.Contains(body, "1–3 of 3") {
|
||||||
|
t.Errorf("page 2 of a one-page result does not re-read at page 1:\n%s", body)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Title of asura:a", "Title of asura:b", "Title of asura:c"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("page 2 of a one-page result dropped %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `class="empty"`) {
|
||||||
|
t.Errorf("page 2 of a one-page result renders the empty state:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A filter matching nothing renders the named empty state, still 200, and
|
||||||
|
// keeps the filter selected: an empty hygiene list reads as good news rather
|
||||||
|
// than a broken page.
|
||||||
|
func TestSeriesListEmptyStateNamesTheFilter(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:covered", url: "u", cover: "c", checkedAt: 9000, latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
srv := newRouter(st, testConfig(), nil)
|
||||||
|
|
||||||
|
body := adminSeriesPage(t, srv, st, "?filter=no_cover")
|
||||||
|
if !strings.Contains(body, "No series") {
|
||||||
|
t.Errorf("a matching-nothing filter renders no named empty state:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "No cover") {
|
||||||
|
t.Errorf("the empty state does not name the filter:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `<option value="no_cover" selected>`) {
|
||||||
|
t.Errorf("the empty filter is not kept selected:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `class="tbl series"`) {
|
||||||
|
t.Errorf("an empty list still renders the table:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hostile title is escaped, not executed: titles come from the database and
|
||||||
|
// from third-party pages, so they are attacker-controlled.
|
||||||
|
func TestSeriesListEscapesHostileTitles(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO series (site, series_id, title, series_url, cover_address, latest_checked_at)
|
||||||
|
VALUES ('asura', 'xss', '<script>alert(1)</script>', 'u', 'c', 9000)`); err != nil {
|
||||||
|
t.Fatalf("seed hostile title: %v", err)
|
||||||
|
}
|
||||||
|
srv := newRouter(st, testConfig(), nil)
|
||||||
|
|
||||||
|
body := adminSeriesPage(t, srv, st, "")
|
||||||
|
if strings.Contains(body, "<script>alert") {
|
||||||
|
t.Errorf("hostile title rendered unescaped:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "<script>") {
|
||||||
|
t.Errorf("hostile title is not escaped:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows are the two-line form, banded by class (never nth-of-type), the site
|
||||||
|
// cell colours by class rather than inline style, chips cap at two plus a +N
|
||||||
|
// tail, and no ember token appears anywhere on the page.
|
||||||
|
func TestSeriesListRowShape(t *testing.T) {
|
||||||
|
st, dsn := newTestStoreURL(t)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", dsn, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
// An orphan with no URL and no cover: three chips, capped to two plus a
|
||||||
|
// tail. The two clean rows sit on either side for the banding.
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:broken", checkedAt: 0, bookmarks: 0})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:mid", url: "u", cover: "c", checkedAt: time.Now().Add(-24 * time.Hour).UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||||
|
srv := newRouter(st, testConfig(), nil)
|
||||||
|
|
||||||
|
body := adminSeriesPage(t, srv, st, "")
|
||||||
|
// Order is least-recently-checked first, then (site, series_id): broken
|
||||||
|
// (never checked), mid (stale), fine (fresh). The middle row carries the
|
||||||
|
// band class; the marked row the attention class.
|
||||||
|
if !(strings.Index(body, "Title of asura:broken") < strings.Index(body, "Title of asura:mid") &&
|
||||||
|
strings.Index(body, "Title of asura:mid") < strings.Index(body, "Title of asura:fine")) {
|
||||||
|
t.Errorf("rows are not in checked order:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Count(body, `class="trow"`) != 1 {
|
||||||
|
t.Errorf("expected exactly one unmarked, unbanded row:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Count(body, `class="trow attention"`) != 1 {
|
||||||
|
t.Errorf("expected exactly one attention row:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Count(body, `class="trow attention band"`) != 1 {
|
||||||
|
t.Errorf("expected exactly one attention band row:\n%s", body)
|
||||||
|
}
|
||||||
|
// The orphan's chips cap at two plus the +N tail.
|
||||||
|
if !strings.Contains(body, `<span class="mark">no URL</span><span class="mark">no cover</span><span class="mark mark-faint">+1</span>`) {
|
||||||
|
t.Errorf("chips do not cap at two plus a tail:\n%s", body)
|
||||||
|
}
|
||||||
|
// The site cell is a class, never the design's inline style.
|
||||||
|
if strings.Contains(body, `style="color:var(--`) {
|
||||||
|
t.Errorf("a site cell carries an inline style:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `class="c-site site-asura"`) {
|
||||||
|
t.Errorf("the site cell lacks its site class:\n%s", body)
|
||||||
|
}
|
||||||
|
// The action cell is present and empty for the sibling ticket, and no
|
||||||
|
// Remove control or confirm row renders in this batch.
|
||||||
|
if !strings.Contains(body, `<span class="c-act"></span>`) {
|
||||||
|
t.Errorf("the action cell is not present and empty:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") {
|
||||||
|
t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body)
|
||||||
|
}
|
||||||
|
// No ember: the new-chapter signal stays off the admin surface. Scoped to
|
||||||
|
// the page content — the shell's brand mark legitimately wears the ember
|
||||||
|
// flame on every page, admin or not.
|
||||||
|
mainStart := strings.Index(body, `<main class="page admin-page">`)
|
||||||
|
mainEnd := strings.Index(body, `</main>`)
|
||||||
|
if mainStart < 0 || mainEnd < 0 || mainStart > mainEnd {
|
||||||
|
t.Fatalf("no page content region to check:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body[mainStart:mainEnd], "--ember") {
|
||||||
|
t.Errorf("the page content carries an ember token:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user