diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index dac582b..aca436f 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -5,7 +5,6 @@ import ( "errors" "log" "net/url" - "sort" "sync" "time" @@ -173,14 +172,7 @@ func fetcherFor(site string, browser, tls Fetcher) Fetcher { // laneNames returns every registry Site in the deterministic order both Run // and runOnce iterate: sorted, so lane behaviour and its tests agree on who // runs first. -func laneNames() []string { - names := make([]string, 0, len(sites)) - for name := range sites { - names = append(names, name) - } - sort.Strings(names) - return names -} +func laneNames() []string { return SiteNames() } func (p *Poller) Run(ctx context.Context) { names := laneNames() diff --git a/backend/internal/latest/sites.go b/backend/internal/latest/sites.go index 64580b4..dd179cf 100644 --- a/backend/internal/latest/sites.go +++ b/backend/internal/latest/sites.go @@ -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 // read through the browser sidecar. Sorted so callers that range it (the // browser fetcher's dispatch) see a stable order instead of map-iteration diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index 3afcaa1..08aa2eb 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -18,6 +18,10 @@ type LaneReporter interface { 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 // fragment returned after a Reader action. type adminView struct { @@ -25,8 +29,9 @@ type adminView struct { Readers []store.ReaderSummary // OwnerID travels with the roster so it can tell the owner's own row from // the Readers they may act on. - OwnerID int64 - Lanes lanesView + OwnerID int64 + Lanes lanesView + SeriesList seriesListView } // 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()}) } -// 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) { h.render(w, http.StatusOK, "admin", view) } diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go new file mode 100644 index 0000000..6b98d35 --- /dev/null +++ b/backend/internal/web/admin_series.go @@ -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() +} diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index 3c0d311..9cc4126 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -240,12 +240,20 @@ color: var(--patina); } +.admin-sheet .tbl .trow.attention .c-title { + color: var(--patina); +} + .admin-sheet .tbl .c-ch, .admin-sheet .tbl .c-rd { text-align: right; padding-right: 26px; } +.admin-sheet .tbl .trow .c-note .mark { + margin-right: 8px; +} + .admin-sheet .tbl .c-act { text-align: right; } @@ -368,6 +376,42 @@ 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 { display: grid; grid-column: 1 / -1; @@ -434,6 +478,12 @@ border-bottom-color: var(--patina); } +.admin-sheet .fsel select option, +.admin-sheet .pausebar select option { + background: var(--ink); + color: var(--paper); +} + .admin-sheet .segrow { display: inline-flex; gap: 2px; @@ -480,6 +530,14 @@ text-transform: uppercase; } +.admin-sheet .pager .pg { + color: var(--paper-dim); +} + +.admin-sheet .pager .pg:hover { + color: var(--paper); +} + .admin-sheet .pager .pg.disabled { color: var(--faint); pointer-events: none; @@ -497,6 +555,7 @@ .admin-sheet .empty p { margin: 6px 0 0; + font: 14px/1.5 var(--font-body); color: var(--mute); } diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html index 1f60e20..5684c26 100644 --- a/backend/internal/web/templates/admin.html +++ b/backend/internal/web/templates/admin.html @@ -39,7 +39,7 @@ {{else if eq .Page "readers"}} {{template "readers" .}} {{else if eq .Page "series"}} -

Series

+ {{template "series-list" .SeriesList}} {{else}}

Overview

{{end}} diff --git a/backend/internal/web/templates/series-list.html b/backend/internal/web/templates/series-list.html new file mode 100644 index 0000000..682c75c --- /dev/null +++ b/backend/internal/web/templates/series-list.html @@ -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"}} +
+ + + + + both + manga + novels + +
+
{{.Total}} series · {{.FilterLabel}}
+{{if .Rows}} +
+
SiteChCheckedReadersNotes
+ {{range .Rows}} +
+ {{.Title}} + {{.Site}} + {{.Ch}} + {{.Age}} + {{.Readers}} + {{range .Notes}}{{.}}{{end}}{{if .More}}+{{.More}}{{end}} + +
+ {{end}} +
+
+ {{if .PrevHref}}‹ prev{{else}}‹ prev{{end}} + {{.Range}} + {{if .NextHref}}next ›{{else}}next ›{{end}} +
+{{else}} +
No series

Nothing matches {{.FilterLabel}}.

+{{end}} +{{end}} diff --git a/backend/web_test.go b/backend/web_test.go index dcfd21d..b8b2970 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -1840,3 +1840,406 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) { 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, `