Latest Chapter correction: one numeric input, owner-gated (#149)
This commit is contained in:
@@ -41,7 +41,8 @@ type SeriesFilter struct {
|
|||||||
// must never leave the store package — so the projection does not select it,
|
// must never leave the store package — so the projection does not select it,
|
||||||
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
||||||
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at`
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
|
||||||
|
s.latest_corrected_at`
|
||||||
|
|
||||||
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||||
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||||
@@ -72,8 +73,14 @@ type AdminSeries struct {
|
|||||||
// meaning never asked. Pending is derived, never stored: a request is
|
// meaning never asked. Pending is derived, never stored: a request is
|
||||||
// pending while ForcePollAt is newer than LatestCheckedAt.
|
// pending while ForcePollAt is newer than LatestCheckedAt.
|
||||||
ForcePollAt int64
|
ForcePollAt int64
|
||||||
|
// LatestCorrectedAt is the correction stamp (issue #149): non-zero means
|
||||||
|
// the Latest Chapter is the owner's, zero means never corrected. The
|
||||||
|
// provenance line (#152) derives from it, so the zero-means-never meaning
|
||||||
|
// is load-bearing.
|
||||||
|
LatestCorrectedAt int64
|
||||||
ReaderCount int
|
ReaderCount int
|
||||||
RaisedByReader bool // a Reader's report set LatestChapterNum
|
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeriesPage is one page of the owner's filtered Series list plus the count
|
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||||
@@ -189,7 +196,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
|
|||||||
`+where+`
|
`+where+`
|
||||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||||
s.force_poll_at, s.latest_raised_by
|
s.force_poll_at, s.latest_corrected_at, s.latest_raised_by
|
||||||
`+having+`
|
`+having+`
|
||||||
ORDER BY s.latest_checked_at, s.site, s.series_id
|
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||||
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
||||||
@@ -264,7 +271,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
|||||||
if err := scan(
|
if err := scan(
|
||||||
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
||||||
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
||||||
&a.ForcePollAt,
|
&a.ForcePollAt, &a.LatestCorrectedAt,
|
||||||
&a.RaisedByReader, &a.ReaderCount, &total,
|
&a.RaisedByReader, &a.ReaderCount, &total,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return AdminSeries{}, 0, err
|
return AdminSeries{}, 0, err
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- latest_corrected_at is the "the current Latest Chapter is the owner's" stamp
|
||||||
|
-- (#149). Written by the Correction; zeroed by every machine write of the
|
||||||
|
-- value. Zero means never corrected.
|
||||||
|
ALTER TABLE series ADD COLUMN latest_corrected_at bigint NOT NULL DEFAULT 0;
|
||||||
@@ -904,6 +904,11 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
|
|||||||
// xmax is zero only on a row this statement inserted, which is how a
|
// xmax is zero only on a row this statement inserted, which is how a
|
||||||
// Series nobody had bookmarked before is told apart from one that already
|
// Series nobody had bookmarked before is told apart from one that already
|
||||||
// existed — DO UPDATE returns a row either way.
|
// existed — DO UPDATE returns a row either way.
|
||||||
|
// latest_corrected_at is the one clause conditional on the value moving
|
||||||
|
// (#149): after a Correction a Reader's cached row holds the corrected
|
||||||
|
// number and resends it on the next Progress PUT, so unconditional
|
||||||
|
// zeroing would erase the fact while the value is still the owner's. The
|
||||||
|
// stamp survives a same-number PUT and dies the moment the number moves.
|
||||||
var created bool
|
var created bool
|
||||||
if err := tx.QueryRow(`
|
if err := tx.QueryRow(`
|
||||||
INSERT INTO series (site, series_id, title, series_url, kind,
|
INSERT INTO series (site, series_id, title, series_url, kind,
|
||||||
@@ -914,7 +919,10 @@ func (s *Store) Upsert(readerID int64, b Bookmark) (Bookmark, error) {
|
|||||||
ON CONFLICT (site, series_id) DO UPDATE SET
|
ON CONFLICT (site, series_id) DO UPDATE SET
|
||||||
kind=excluded.kind,
|
kind=excluded.kind,
|
||||||
latest_chapter=excluded.latest_chapter,
|
latest_chapter=excluded.latest_chapter,
|
||||||
latest_chapter_num=excluded.latest_chapter_num
|
latest_chapter_num=excluded.latest_chapter_num,
|
||||||
|
latest_corrected_at = CASE
|
||||||
|
WHEN series.latest_chapter_num IS DISTINCT FROM excluded.latest_chapter_num
|
||||||
|
THEN 0 ELSE series.latest_corrected_at END
|
||||||
RETURNING xmax = 0`,
|
RETURNING xmax = 0`,
|
||||||
b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Kind,
|
b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Kind,
|
||||||
b.LatestChapter, latestNum).Scan(&created); err != nil {
|
b.LatestChapter, latestNum).Scan(&created); err != nil {
|
||||||
@@ -1320,10 +1328,13 @@ func (s *Store) LatestCheckedAt(site, seriesID string) (int64, error) {
|
|||||||
// SetLatestChapter records the newest chapter the poll found on a series page.
|
// SetLatestChapter records the newest chapter the poll found on a series page.
|
||||||
// The poller walks Series rather than Bookmarks, so this is a series-level
|
// The poller walks Series rather than Bookmarks, so this is a series-level
|
||||||
// write: the row is shared, and updating it once refreshes every bookmark that
|
// write: the row is shared, and updating it once refreshes every bookmark that
|
||||||
// joins to it. Touching a missing series is not an error.
|
// joins to it. Touching a missing series is not an error. The correction stamp
|
||||||
|
// is zeroed unconditionally: checkOne only calls this when the number differs,
|
||||||
|
// so a second copy of the condition would drift (#149).
|
||||||
func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) error {
|
func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) error {
|
||||||
if _, err := s.db.Exec(
|
if _, err := s.db.Exec(
|
||||||
`UPDATE series SET latest_chapter = $3, latest_chapter_num = $4
|
`UPDATE series SET latest_chapter = $3, latest_chapter_num = $4,
|
||||||
|
latest_corrected_at = 0
|
||||||
WHERE site = $1 AND series_id = $2`,
|
WHERE site = $1 AND series_id = $2`,
|
||||||
site, seriesID, label, num); err != nil {
|
site, seriesID, label, num); err != nil {
|
||||||
return fmt.Errorf("set latest chapter %s:%s: %w", site, seriesID, err)
|
return fmt.Errorf("set latest chapter %s:%s: %w", site, seriesID, err)
|
||||||
@@ -1331,8 +1342,27 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordSighting notes that a Reader's browser reported this Series' Latest
|
// CorrectLatestChapter makes the Latest Chapter the owner's: one UPDATE
|
||||||
// Chapter, which is the half of a Sighting the client body cannot express
|
// carrying the number, the derived label and the correction stamp. The label
|
||||||
|
// shape is the poller's and the userscript's ("Chapter " + the number as
|
||||||
|
// printed), so chapterLeadIn strips it and the UI renders "Ch N" with no
|
||||||
|
// special case. latest_checked_at is not touched: a Correction is not a check.
|
||||||
|
// A raising Reader is cleared without judgement: the number is the owner's
|
||||||
|
// now, and no Sighting counter moves (spec #135).
|
||||||
|
func (s *Store) CorrectLatestChapter(site, seriesID string, num float64, at int64) error {
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE series SET
|
||||||
|
latest_chapter = $3,
|
||||||
|
latest_chapter_num = $4,
|
||||||
|
latest_corrected_at = $5,
|
||||||
|
latest_raised_by = NULL
|
||||||
|
WHERE site = $1 AND series_id = $2`,
|
||||||
|
site, seriesID, "Chapter "+strconv.FormatFloat(num, 'f', -1, 64), num, at); err != nil {
|
||||||
|
return fmt.Errorf("correct latest chapter %s:%s: %w", site, seriesID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// (issue #103). It must be called *before* the Upsert that stores the reported
|
// (issue #103). It must be called *before* the Upsert that stores the reported
|
||||||
// value: the raise test compares against what is still on the row, and after
|
// value: the raise test compares against what is still on the row, and after
|
||||||
// the Upsert there is nothing left to compare with. A Series that does not
|
// the Upsert there is nothing left to compare with. A Series that does not
|
||||||
|
|||||||
@@ -1899,3 +1899,133 @@ func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
|
|||||||
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
|
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Correction writes the number, the derived label and the stamp, clears the
|
||||||
|
// raising Reader, and never touches either Sighting counter or the check
|
||||||
|
// stamp — a Correction is not a check and never judges a Reader (#149).
|
||||||
|
func TestCorrectLatestChapterStampsClearsAndDoesNotTouchCheckOrMarks(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
other := secondReader(t, s)
|
||||||
|
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 4321_000)
|
||||||
|
// A Reader raised the number, and carries a mark for it.
|
||||||
|
if err := s.RecordSighting(other, "asura", "solo", num2(3), 1000); err != nil {
|
||||||
|
t.Fatalf("RecordSighting: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`
|
||||||
|
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
|
||||||
|
WHERE id = $1`, other); err != nil {
|
||||||
|
t.Fatalf("mark reader: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.CorrectLatestChapter("asura", "solo", 12.5, 9000); err != nil {
|
||||||
|
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chapter string
|
||||||
|
var num float64
|
||||||
|
var stamp, checkedAt int64
|
||||||
|
var raisedBy any
|
||||||
|
if err := s.db.QueryRow(`
|
||||||
|
SELECT latest_chapter, latest_chapter_num, latest_corrected_at,
|
||||||
|
latest_checked_at, latest_raised_by
|
||||||
|
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
||||||
|
Scan(&chapter, &num, &stamp, &checkedAt, &raisedBy); err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if chapter != "Chapter 12.5" {
|
||||||
|
t.Errorf("latest_chapter = %q, want the derived label %q", chapter, "Chapter 12.5")
|
||||||
|
}
|
||||||
|
if num != 12.5 {
|
||||||
|
t.Errorf("latest_chapter_num = %v, want 12.5", num)
|
||||||
|
}
|
||||||
|
if stamp != 9000 {
|
||||||
|
t.Errorf("latest_corrected_at = %d, want 9000", stamp)
|
||||||
|
}
|
||||||
|
if checkedAt != 4321_000 {
|
||||||
|
t.Errorf("latest_checked_at = %d, want the untouched 4321000", checkedAt)
|
||||||
|
}
|
||||||
|
if raisedBy != nil {
|
||||||
|
t.Errorf("latest_raised_by = %v, want the attribution cleared", raisedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
readers, err := s.Readers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Readers: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range readers {
|
||||||
|
if r.ID == other && (r.Agreements != 5 || r.Disagreements != 2) {
|
||||||
|
t.Errorf("raising reader's marks = %+v, want agreements 5, disagreements 2 unchanged", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stamp follows the number (spec #135): an Upsert resending the corrected
|
||||||
|
// value — a Reader's cached row after a correction — keeps it, and an Upsert
|
||||||
|
// that actually moves the number kills it. Unconditional zeroing would erase
|
||||||
|
// the fact while the value is still the owner's; that is the whole point of
|
||||||
|
// the clause.
|
||||||
|
func TestUpsertCorrectionStampFollowsTheNumber(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
base := Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
|
||||||
|
SeriesURL: "https://asurascans.com/comics/solo", UpdatedAt: 1000,
|
||||||
|
}
|
||||||
|
if _, err := s.Upsert(s.OwnerID(), base); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
|
||||||
|
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same number back: the value is still the owner's.
|
||||||
|
same := base
|
||||||
|
same.LatestChapterNum = num2(5)
|
||||||
|
if _, err := s.Upsert(s.OwnerID(), same); err != nil {
|
||||||
|
t.Fatalf("same-number upsert: %v", err)
|
||||||
|
}
|
||||||
|
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 9000 {
|
||||||
|
t.Fatalf("stamp after same-number Upsert = %d, want 9000 kept", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different number: a machine (or a Reader) wrote the value.
|
||||||
|
moved := base
|
||||||
|
moved.LatestChapterNum = num2(7)
|
||||||
|
if _, err := s.Upsert(s.OwnerID(), moved); err != nil {
|
||||||
|
t.Fatalf("moved upsert: %v", err)
|
||||||
|
}
|
||||||
|
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
|
||||||
|
t.Fatalf("stamp after moved Upsert = %d, want zeroed", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The poller's chapter setter zeroes the stamp unconditionally: checkOne only
|
||||||
|
// calls it when the number differs, so the condition lives upstream and a
|
||||||
|
// second copy here would drift (#149).
|
||||||
|
func TestSetLatestChapterZeroesCorrectionStamp(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||||
|
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
|
||||||
|
t.Fatalf("CorrectLatestChapter: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.SetLatestChapter("asura", "solo", "Chapter 6", 6); err != nil {
|
||||||
|
t.Fatalf("SetLatestChapter: %v", err)
|
||||||
|
}
|
||||||
|
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
|
||||||
|
t.Fatalf("stamp after a machine write = %d, want zeroed", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestCorrectedAt reads the stamp column for the assertion above.
|
||||||
|
func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var stamp int64
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
`SELECT latest_corrected_at FROM series WHERE site = $1 AND series_id = $2`,
|
||||||
|
site, seriesID).Scan(&stamp); err != nil {
|
||||||
|
t.Fatalf("read stamp: %v", err)
|
||||||
|
}
|
||||||
|
return stamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// num2 boxes a chapter number for the Bookmark fields that take a pointer.
|
||||||
|
func num2(f float64) *float64 { return &f }
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func (h *Handler) adminRoutes() []adminRoute {
|
|||||||
{"GET /admin/series", h.adminSeries},
|
{"GET /admin/series", h.adminSeries},
|
||||||
{"GET /admin/series/{key}", h.adminSeriesDetail},
|
{"GET /admin/series/{key}", h.adminSeriesDetail},
|
||||||
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
|
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
|
||||||
|
{"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest},
|
||||||
{"POST /admin/lanes/{site}/pause", h.adminLanePause},
|
{"POST /admin/lanes/{site}/pause", h.adminLanePause},
|
||||||
{"POST /admin/lanes/{site}/resume", h.adminLaneResume},
|
{"POST /admin/lanes/{site}/resume", h.adminLaneResume},
|
||||||
{"GET /ui/admin/lanes", h.uiLanes},
|
{"GET /ui/admin/lanes", h.uiLanes},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -172,6 +173,58 @@ func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
// seriesListView assembles one Series list view from the request's query
|
// 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
|
// 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.
|
// select's options are not the only way this URL can be reached.
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type seriesDetailView struct {
|
|||||||
Cover string
|
Cover string
|
||||||
Chapter string // Latest Chapter number, or "—" before the first capture
|
Chapter string // Latest Chapter number, or "—" before the first capture
|
||||||
Checked string // how long ago the poller last checked, or "never"
|
Checked string // how long ago the poller last checked, or "never"
|
||||||
|
// Corrected is the correction marker's text, "" while no Correction
|
||||||
|
// stands: "corrected <age> ago" — the copy that says the value is the
|
||||||
|
// owner's, and it dies with the stamp (a machine write of the number).
|
||||||
|
Corrected string
|
||||||
Readers int
|
Readers int
|
||||||
|
|
||||||
// Marks, one per hygiene fact, rendered only while it holds.
|
// Marks, one per hygiene fact, rendered only while it holds.
|
||||||
@@ -31,7 +35,6 @@ type seriesDetailView struct {
|
|||||||
NoCover bool
|
NoCover bool
|
||||||
Orphan bool // no Reader holds the Series
|
Orphan bool // no Reader holds the Series
|
||||||
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
||||||
|
|
||||||
// Poll is the Check now control and the pending marker (issue #146): the
|
// Poll is the Check now control and the pending marker (issue #146): the
|
||||||
// same derivation and visibility as the list row. CanPoll is false on a
|
// same derivation and visibility as the list row. CanPoll is false on a
|
||||||
// Series with no page to fetch and on an orphan; Pending is derived —
|
// Series with no page to fetch and on an orphan; Pending is derived —
|
||||||
@@ -122,5 +125,16 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
|||||||
} else {
|
} else {
|
||||||
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
||||||
}
|
}
|
||||||
|
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// correctedAge is the correction marker's text: "corrected <age> ago" while
|
||||||
|
// the stamp is set, "" when zero — zero means never corrected, and the marker
|
||||||
|
// must not read as history once a machine wrote the number.
|
||||||
|
func correctedAge(now time.Time, at int64) string {
|
||||||
|
if at == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "corrected " + since(now, time.UnixMilli(at))
|
||||||
|
}
|
||||||
|
|||||||
@@ -720,6 +720,27 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sheet .dform input {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--field-line);
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
font: 500 14px var(--font-mono);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
/* Focus follows the chapter form's idiom — paper, not heat: a red border on
|
||||||
|
a valid number field reads as "invalid". */
|
||||||
|
.admin-sheet .dform input:focus {
|
||||||
|
border-color: var(--paper);
|
||||||
|
}
|
||||||
|
.admin-sheet .dform .hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mute-2);
|
||||||
|
font: 500 12px/1.4 var(--font-mono);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .pausebar {
|
.admin-sheet .pausebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
|
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
|
||||||
list row is one hop from it. Everything here is a Series-level fact plus
|
list row is one hop from it. Everything here is a Series-level fact plus
|
||||||
the anonymous Reader count. The Check now control lands in its own .dform
|
the anonymous Reader count. The Check now control lands in its own .dform
|
||||||
below the (empty) .detail-grid; the pending marker rides the meta line
|
below the .detail-grid; the correction form is the grid's first column,
|
||||||
with the other marks. */}}
|
and the grid's second waits for the series-URL repair (#151). The pending
|
||||||
|
and corrected markers ride the meta line with the other marks. */}}
|
||||||
{{define "series-detail"}}
|
{{define "series-detail"}}
|
||||||
<a class="ghost detail-back" href="/admin/series">← Series</a>
|
<a class="ghost detail-back" href="/admin/series">← Series</a>
|
||||||
<h1 class="detail-title">{{.Title}}</h1>
|
<h1 class="detail-title">{{.Title}}</h1>
|
||||||
@@ -10,7 +11,16 @@
|
|||||||
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
||||||
{{else}}<div class="cover"></div>{{end}}
|
{{else}}<div class="cover"></div>{{end}}
|
||||||
{{template "series-detail-meta" .}}
|
{{template "series-detail-meta" .}}
|
||||||
<div class="detail-grid"></div>
|
<div class="detail-grid">
|
||||||
|
<form class="dform" hx-post="/admin/series/{{.Key}}/latest" hx-target="#detail-meta" hx-swap="outerHTML">
|
||||||
|
<h3>Correct latest chapter</h3>
|
||||||
|
<p class="hint">The next successful Poll overwrites this value.</p>
|
||||||
|
<div class="field">
|
||||||
|
<input type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required>
|
||||||
|
<button type="submit" class="ghost">Set</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{{if .CanPoll}}
|
{{if .CanPoll}}
|
||||||
<div class="dform">
|
<div class="dform">
|
||||||
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
|
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
|
||||||
@@ -18,14 +28,15 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* series-detail-meta is the meta line, and the answer a Check now press on
|
{{/* series-detail-meta is the meta line, and the answer a Check now or
|
||||||
the detail page swaps into its place: the same marks, re-rendered after
|
correction press on the detail page swaps into its place: the same marks,
|
||||||
the stamp so the pending marker shows. */}}
|
re-rendered after the stamp so the pending and corrected markers show. */}}
|
||||||
{{define "series-detail-meta"}}
|
{{define "series-detail-meta"}}
|
||||||
<div class="detail-meta" id="detail-meta">
|
<div class="detail-meta" id="detail-meta">
|
||||||
<span>ch {{.Chapter}}</span>
|
<span>ch {{.Chapter}}</span>
|
||||||
<span>checked {{.Checked}}</span>
|
<span>checked {{.Checked}}</span>
|
||||||
<span>{{.Readers}} readers</span>
|
<span>{{.Readers}} readers</span>
|
||||||
|
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
|
||||||
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
|
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
|
||||||
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
|
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
|
||||||
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
|
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
|
||||||
|
|||||||
@@ -3238,3 +3238,136 @@ func TestSeriesPollCapsBody(t *testing.T) {
|
|||||||
t.Errorf("an oversized body still stamped the request:\n%s", body)
|
t.Errorf("an oversized body still stamped the request:\n%s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The correction route validates at the boundary: a non-numeric, zero,
|
||||||
|
// negative or non-finite chapter answers 400 and never reaches the store, and
|
||||||
|
// a finite number greater than zero stores the number, the derived label and
|
||||||
|
// the stamp. The answer is the freshly rendered meta fragment, so the figures
|
||||||
|
// describe the state after the press (#149).
|
||||||
|
func TestCorrectLatestChapterRoute(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:solo", url: "u", checkedAt: 9000, bookmarks: 1, latestNum: floatPtr(3),
|
||||||
|
})
|
||||||
|
router := newRouter(st, testConfig())
|
||||||
|
cookie := sessionCookie(t, st)
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
"chapter=abc", "chapter=", "chapter=0", "chapter=-1", "chapter=NaN", "chapter=Inf",
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("POST latest with body %q: status = %d, want 400", body, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing reached the store: the seeded number stands, unstamped.
|
||||||
|
var num float64
|
||||||
|
var stamp int64
|
||||||
|
if err := db.QueryRow(`
|
||||||
|
SELECT latest_chapter_num, latest_corrected_at
|
||||||
|
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
||||||
|
Scan(&num, &stamp); err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if num != 3 || stamp != 0 {
|
||||||
|
t.Fatalf("after 400s the row is num %v, stamp %d; want 3, 0", num, stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A good press stores the number, the derived label and the stamp, and
|
||||||
|
// answers with the meta fragment describing the state after the press.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=12.5"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST latest status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, `id="detail-meta"`) {
|
||||||
|
t.Errorf("correction answer is not the meta fragment:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `<span class="mark">corrected `) {
|
||||||
|
t.Errorf("correction answer lacks the fresh corrected marker:\n%s", body)
|
||||||
|
}
|
||||||
|
var label string
|
||||||
|
if err := db.QueryRow(`
|
||||||
|
SELECT latest_chapter, latest_chapter_num, latest_corrected_at
|
||||||
|
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
||||||
|
Scan(&label, &num, &stamp); err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if label != "Chapter 12.5" || num != 12.5 {
|
||||||
|
t.Errorf("stored = %q, %v; want the derived label and 12.5", label, num)
|
||||||
|
}
|
||||||
|
if stamp == 0 {
|
||||||
|
t.Error("stamp = 0, want the correction stamp written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The detail page offers the one-input correction with the plain copy, and
|
||||||
|
// the corrected marker rides the meta line while the stamp is set — then
|
||||||
|
// disappears the moment a machine writes the number (#149).
|
||||||
|
func TestAdminSeriesDetailCorrectionMarker(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:solo", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||||
|
router := newRouter(st, testConfig())
|
||||||
|
cookie := sessionCookie(t, st)
|
||||||
|
|
||||||
|
body := seriesDetailPage(t, router, st, "asura:solo")
|
||||||
|
for _, want := range []string{
|
||||||
|
`name="chapter"`,
|
||||||
|
`hx-post="/admin/series/asura:solo/latest"`,
|
||||||
|
"The next successful Poll overwrites this value.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("detail page lacks %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "corrected ") {
|
||||||
|
t.Errorf("uncorrected detail already carries the marker:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The press lands the marker on the meta line.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/latest", strings.NewReader("chapter=7"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST latest status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body = seriesDetailPage(t, router, st, "asura:solo")
|
||||||
|
if !strings.Contains(body, `<span class="mark">corrected `) {
|
||||||
|
t.Errorf("detail page lacks the corrected marker after the press:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "ch 7") {
|
||||||
|
t.Errorf("detail page does not show the corrected number:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine write (the poller's setter) kills the marker.
|
||||||
|
if err := st.SetLatestChapter("asura", "solo", "Chapter 8", 8); err != nil {
|
||||||
|
t.Fatalf("SetLatestChapter: %v", err)
|
||||||
|
}
|
||||||
|
body = seriesDetailPage(t, router, st, "asura:solo")
|
||||||
|
if strings.Contains(body, "corrected ") {
|
||||||
|
t.Errorf("marker survives a machine write:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "ch 8") {
|
||||||
|
t.Errorf("detail page does not show the machine-written number:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user