Merge #158: Owner Finish control on the Series detail page
This commit is contained in:
@@ -42,7 +42,7 @@ type SeriesFilter struct {
|
|||||||
// 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`
|
s.latest_corrected_at, s.finished_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 —
|
||||||
@@ -80,7 +80,9 @@ type AdminSeries struct {
|
|||||||
LatestCorrectedAt int64
|
LatestCorrectedAt int64
|
||||||
ReaderCount int
|
ReaderCount int
|
||||||
RaisedByReader bool // a Reader's report set LatestChapterNum
|
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||||
|
// FinishedAt is the owner's finish stamp: unix ms, zero while the Series is
|
||||||
|
// not finished — the same shape as the Correction stamp, and its own undo.
|
||||||
|
FinishedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -196,7 +198,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_corrected_at, s.latest_raised_by
|
s.force_poll_at, s.latest_corrected_at, s.finished_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...)
|
||||||
@@ -271,7 +273,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.LatestCorrectedAt,
|
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt,
|
||||||
&a.RaisedByReader, &a.ReaderCount, &total,
|
&a.RaisedByReader, &a.ReaderCount, &total,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return AdminSeries{}, 0, err
|
return AdminSeries{}, 0, err
|
||||||
|
|||||||
@@ -394,3 +394,57 @@ func TestAdminSeriesCarriesForcePollAt(t *testing.T) {
|
|||||||
t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows)
|
t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSeriesFinished is the owner's finish stamp write: finishing writes the
|
||||||
|
// given ms, un-finishing writes zero — the one undo, the same shape as the
|
||||||
|
// correction stamp. Touching a missing series is not an error: the row may
|
||||||
|
// have been orphaned, and the caller's read decides what exists.
|
||||||
|
func TestSetSeriesFinishedStampsAndClears(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1})
|
||||||
|
|
||||||
|
if err := s.SetSeriesFinished("asura", "x", 42); err != nil {
|
||||||
|
t.Fatalf("SetSeriesFinished: %v", err)
|
||||||
|
}
|
||||||
|
var got int64
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
|
||||||
|
t.Fatalf("read finished_at: %v", err)
|
||||||
|
}
|
||||||
|
if got != 42 {
|
||||||
|
t.Fatalf("finished_at = %d, want 42", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetSeriesFinished("asura", "x", 0); err != nil {
|
||||||
|
t.Fatalf("SetSeriesFinished un-finish: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
|
||||||
|
t.Fatalf("read finished_at after un-finish: %v", err)
|
||||||
|
}
|
||||||
|
if got != 0 {
|
||||||
|
t.Fatalf("finished_at = %d, want 0 (un-finish writes zero)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetSeriesFinished("asura", "ghost", 42); err != nil {
|
||||||
|
t.Fatalf("SetSeriesFinished missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin projection carries the finish stamp so the web layer can render
|
||||||
|
// the finished state without a second read.
|
||||||
|
func TestAdminSeriesCarriesFinishedAt(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1})
|
||||||
|
if err := s.SetSeriesFinished("asura", "x", 5000); err != nil {
|
||||||
|
t.Fatalf("SetSeriesFinished: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := s.SeriesPage(SeriesFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SeriesPage: %v", err)
|
||||||
|
}
|
||||||
|
if len(page.Rows) != 1 || page.Rows[0].FinishedAt != 5000 {
|
||||||
|
t.Fatalf("row = %+v, want FinishedAt 5000", page.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1440,6 +1440,21 @@ func (s *Store) ForceSeriesPoll(site, seriesID string, at int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSeriesFinished stamps or clears the owner's finish. at is unix ms to
|
||||||
|
// finish, zero to un-finish. A finished Series drops out of the Lane's reads
|
||||||
|
// (issue #157), and nothing else writes this column: it is the only writer
|
||||||
|
// outside migration 0016, so a machine write can never retire a Series
|
||||||
|
// silently. Touching a missing series is not an error: the row may have been
|
||||||
|
// orphaned, and the caller's read decides what exists.
|
||||||
|
func (s *Store) SetSeriesFinished(site, seriesID string, at int64) error {
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE series SET finished_at = $1 WHERE site = $2 AND series_id = $3`,
|
||||||
|
at, site, seriesID); err != nil {
|
||||||
|
return fmt.Errorf("set series finished %s:%s: %w", site, seriesID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
|
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
|
||||||
// tests outside this package (the poller's own tests assert on rest
|
// tests outside this package (the poller's own tests assert on rest
|
||||||
// bookkeeping) — see MarkLatestChecked for why the field stays off the
|
// bookkeeping) — see MarkLatestChecked for why the field stays off the
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ 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}/finish", h.adminSeriesFinish},
|
||||||
|
{"POST /admin/series/{key}/unfinish", h.adminSeriesUnfinish},
|
||||||
{"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest},
|
{"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest},
|
||||||
{"POST /admin/series/{key}/series-url", h.adminSeriesSetURL},
|
{"POST /admin/series/{key}/series-url", h.adminSeriesSetURL},
|
||||||
{"POST /admin/series/{key}/remove", h.adminSeriesRemove},
|
{"POST /admin/series/{key}/remove", h.adminSeriesRemove},
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ type seriesRowView struct {
|
|||||||
// raced answer: the row stays and says a fresh Bookmark caught the press.
|
// raced answer: the row stays and says a fresh Bookmark caught the press.
|
||||||
CanRemove bool
|
CanRemove bool
|
||||||
RemovalRefused bool
|
RemovalRefused bool
|
||||||
|
// Finished is the row's display of the owner's finish stamp: the list row
|
||||||
|
// shows the state and never offers the control — that lives on the detail
|
||||||
|
// page, where a press that retires a Series from the Lane is on purpose
|
||||||
|
// and confirm-gated (issue #158).
|
||||||
|
Finished bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
||||||
@@ -182,6 +187,96 @@ 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()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminSeriesFinish is the owner's Finish control: it stamps the Series'
|
||||||
|
// finished_at and answers with the freshly rendered meta fragment, so the
|
||||||
|
// "finished <age> ago" line describes the state after the press. The Lane's
|
||||||
|
// next pass reads the stamp and stops polling the Series (issue #157). The
|
||||||
|
// owner gate is the route's, not this handler's; the body is capped like the
|
||||||
|
// API path caps its bodies; the key is validated here — a malformed key is a
|
||||||
|
// 400 and an unknown one a 404.
|
||||||
|
func (h *Handler) adminSeriesFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||||
|
if !ok || site == "" || seriesID == "" {
|
||||||
|
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||||
|
log.Printf("series finish %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
} else if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.SetSeriesFinished(site, seriesID, time.Now().UnixMilli()); err != nil {
|
||||||
|
log.Printf("series finish %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-read after the stamp: the answer must describe the state after the
|
||||||
|
// press, so the line reads "finished just now". The control's one caller
|
||||||
|
// is the detail page, which swaps the meta fragment in place.
|
||||||
|
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("series finish %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSeriesUnfinish is the reversal of the Finish control: it clears the
|
||||||
|
// stamp (writes zero) and answers with the freshly rendered meta fragment, so
|
||||||
|
// the Series is back in the Lane's queue from its next pass. Reversal, so it
|
||||||
|
// fires instantly with no confirm row (issue #158).
|
||||||
|
func (h *Handler) adminSeriesUnfinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||||
|
if !ok || site == "" || seriesID == "" {
|
||||||
|
http.Error(w, "bad series key", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||||
|
log.Printf("series unfinish %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
} else if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.SetSeriesFinished(site, seriesID, 0); err != nil {
|
||||||
|
log.Printf("series unfinish %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-read after the write: the answer must describe the state after the
|
||||||
|
// press, so the fragment no longer carries the finished line.
|
||||||
|
a, found, err := h.adminSeriesByKey(site, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("series unfinish %s: %v", site+":"+seriesID, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
|
||||||
|
}
|
||||||
|
|
||||||
// adminSeriesCorrectLatest is the Latest Chapter correction: the owner types
|
// adminSeriesCorrectLatest is the Latest Chapter correction: the owner types
|
||||||
// one number and the Series' Latest Chapter becomes it, stamped as a
|
// one number and the Series' Latest Chapter becomes it, stamped as a
|
||||||
// Correction. The number must be a finite float greater than zero — a
|
// Correction. The number must be a finite float greater than zero — a
|
||||||
@@ -572,6 +667,7 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
|||||||
CanRemove: a.ReaderCount == 0,
|
CanRemove: a.ReaderCount == 0,
|
||||||
Pending: pending,
|
Pending: pending,
|
||||||
Requested: requested,
|
Requested: requested,
|
||||||
|
Finished: a.FinishedAt > 0,
|
||||||
}
|
}
|
||||||
if a.LatestChapterNum != nil {
|
if a.LatestChapterNum != nil {
|
||||||
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ type seriesDetailView struct {
|
|||||||
// Series no Reader holds can be removed, so the owner is never offered a
|
// Series no Reader holds can be removed, so the owner is never offered a
|
||||||
// button the database will always refuse.
|
// button the database will always refuse.
|
||||||
CanRemove bool
|
CanRemove bool
|
||||||
|
// Finished is the owner's finish stamp rendered for the control: while it
|
||||||
|
// stands, the page offers the instant Un-finish, not the confirm-gated
|
||||||
|
// Finish (issue #158).
|
||||||
|
Finished bool
|
||||||
|
// FinishedSince is the "finished <age> ago" line, "" while no finish
|
||||||
|
// stands. It rides the meta fragment both presses swap, so the answer
|
||||||
|
// itself shows how long the Series has been finished.
|
||||||
|
FinishedSince string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||||
@@ -141,6 +149,8 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
|||||||
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)
|
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
||||||
|
v.Finished = a.FinishedAt != 0
|
||||||
|
v.FinishedSince = finishedAge(time.Now(), a.FinishedAt)
|
||||||
|
|
||||||
// Provenance: the actor class behind the current number, evaluated in the
|
// Provenance: the actor class behind the current number, evaluated in the
|
||||||
// order the classes outrank one another — the owner's stamp, which a
|
// order the classes outrank one another — the owner's stamp, which a
|
||||||
@@ -171,3 +181,13 @@ func correctedAge(now time.Time, at int64) string {
|
|||||||
}
|
}
|
||||||
return "corrected " + since(now, time.UnixMilli(at))
|
return "corrected " + since(now, time.UnixMilli(at))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finishedAge is the finish marker's text: "finished <age> ago" while the
|
||||||
|
// stamp is set, "" when zero — zero means never finished, and the reversal
|
||||||
|
// (un-finish) must not read as history after a press (issue #158).
|
||||||
|
func finishedAge(now time.Time, at int64) string {
|
||||||
|
if at == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "finished " + since(now, time.UnixMilli(at))
|
||||||
|
}
|
||||||
|
|||||||
@@ -653,6 +653,22 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The finish confirm is the one calm row on the admin sheet: a reversible
|
||||||
|
move wears the recessed ash, never the remove wash above, and its
|
||||||
|
affirmative takes the sheet's patina accent (issue #158). */
|
||||||
|
.admin-sheet .confirm-row.calm {
|
||||||
|
background: var(--ash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .confirm-row.calm span {
|
||||||
|
color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sheet .confirm-row.calm .go {
|
||||||
|
background: var(--patina);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sheet .tbl.series .row-msg {
|
.admin-sheet .tbl.series .row-msg {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
|||||||
@@ -39,6 +39,22 @@
|
|||||||
<div class="field"><button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta" hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button></div>
|
<div class="field"><button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta" hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button></div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .Finished}}
|
||||||
|
<div class="dform">
|
||||||
|
<div class="field"><button type="button" class="ghost" hx-post="/admin/series/{{.Key}}/unfinish" hx-target="#detail-meta" hx-swap="outerHTML">Un-finish</button></div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="dform">
|
||||||
|
<div class="field"><button type="button" class="ghost" onclick="document.getElementById('confirm-finish').hidden = false">Finish</button></div>
|
||||||
|
<div class="confirm-row calm" id="confirm-finish" role="group" aria-live="polite" hidden>
|
||||||
|
<span>Mark this Series finished?</span>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="go" hx-post="/admin/series/{{.Key}}/finish" hx-target="#detail-meta" hx-swap="outerHTML">Finish</button>
|
||||||
|
<button type="button" onclick="document.getElementById('confirm-finish').hidden = true">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* series-detail-meta is the meta line, and the answer a Check now or
|
{{/* series-detail-meta is the meta line, and the answer a Check now or
|
||||||
@@ -54,6 +70,7 @@
|
|||||||
<span>{{.Readers}} readers</span>
|
<span>{{.Readers}} readers</span>
|
||||||
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
|
{{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 .FinishedSince}}<span class="mark">{{.FinishedSince}}</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}}
|
||||||
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
|
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<span class="c-ch">{{.Ch}}</span>
|
<span class="c-ch">{{.Ch}}</span>
|
||||||
<span>{{.Age}}</span>
|
<span>{{.Age}}</span>
|
||||||
<span class="c-rd">{{.Readers}}</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-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span>
|
||||||
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
|
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
|
||||||
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
|
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3428,6 +3428,169 @@ func TestAdminSeriesDetailCorrectionMarker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The finish control lives on the Series detail page only: a neighbouring
|
||||||
|
// Finish in the fifty-row grid is not a harmless read the way Check now is,
|
||||||
|
// so the list row renders the state as a mark and offers no control to set
|
||||||
|
// it. On the detail page the press is confirm-gated — the opener does not
|
||||||
|
// post and the only finish POST sits inside the confirm row, targeting the
|
||||||
|
// meta fragment it will swap (#158).
|
||||||
|
func TestAdminSeriesDetailFinishControl(t *testing.T) {
|
||||||
|
router, st := newWebTestServer(t, testConfig())
|
||||||
|
seed(t, st, store.Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
|
Title: "Solo", Kind: store.KindManga,
|
||||||
|
})
|
||||||
|
|
||||||
|
body := seriesDetailPage(t, router, st, "asura:solo")
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="confirm-finish"`,
|
||||||
|
"Mark this Series finished?",
|
||||||
|
`hx-post="/admin/series/asura:solo/finish"`,
|
||||||
|
`hx-target="#detail-meta"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("detail page lacks %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The press travels through the confirm row: the page's only finish POST
|
||||||
|
// sits after the row's id (inside the row), and the opener that reveals
|
||||||
|
// it posts nothing.
|
||||||
|
if opener := strings.Index(body, `id="confirm-finish"`); opener < 0 ||
|
||||||
|
strings.Index(body, `hx-post="/admin/series/asura:solo/finish"`) < opener {
|
||||||
|
t.Errorf("finish POST is not inside the confirm row:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`) != 1 {
|
||||||
|
t.Errorf("finish POST count = %d, want exactly one (the affirmative):\n%s",
|
||||||
|
strings.Count(body, `hx-post="/admin/series/asura:solo/finish"`), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list row displays nothing to set: no finish control, no finished
|
||||||
|
// mark on an unfinished Series.
|
||||||
|
list := adminSeriesPage(t, router, st, "")
|
||||||
|
for _, banned := range []string{
|
||||||
|
`/admin/series/asura:solo/finish`,
|
||||||
|
`/admin/series/asura:solo/unfinish`,
|
||||||
|
`mark-faint">finished`,
|
||||||
|
} {
|
||||||
|
if strings.Contains(list, banned) {
|
||||||
|
t.Errorf("list row carries %q:\n%s", banned, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The finish route is the owner's one writer: a finish press stamps
|
||||||
|
// finished_at and answers with the swapped detail-meta fragment carrying the
|
||||||
|
// "finished <age> ago" mark, and an un-finish press writes zero and answers
|
||||||
|
// with a fragment that has no finished line. A malformed key is a 400 and an
|
||||||
|
// unknown one a 404, like the other Series mutations (#158).
|
||||||
|
func TestSeriesFinishRoute(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)
|
||||||
|
|
||||||
|
for path, want := range map[string]int{
|
||||||
|
"/admin/series/solo/finish": http.StatusBadRequest,
|
||||||
|
"/admin/series/ghost:x/finish": http.StatusNotFound,
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(""))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != want {
|
||||||
|
t.Errorf("POST %s: status = %d, want %d", path, rr.Code, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A finish press stamps and answers with the swapped meta fragment whose
|
||||||
|
// mark reads "finished just now".
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/finish", strings.NewReader(""))
|
||||||
|
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 finish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, `id="detail-meta"`) {
|
||||||
|
t.Errorf("finish answer is not the meta fragment:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `<span class="mark">finished `) {
|
||||||
|
t.Errorf("finish answer lacks the fresh finished mark:\n%s", body)
|
||||||
|
}
|
||||||
|
var at int64
|
||||||
|
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
|
||||||
|
t.Fatalf("read finished_at: %v", err)
|
||||||
|
}
|
||||||
|
if at == 0 {
|
||||||
|
t.Error("finished_at = 0, want the finish stamp written")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page after the press offers the instant reversal, no confirm.
|
||||||
|
body = seriesDetailPage(t, router, st, "asura:solo")
|
||||||
|
if !strings.Contains(body, `hx-post="/admin/series/asura:solo/unfinish"`) {
|
||||||
|
t.Errorf("finished detail page lacks the un-finish control:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "Mark this Series finished?") {
|
||||||
|
t.Errorf("finished detail page still carries the confirm row:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An un-finish press fires instantly and writes zero.
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/unfinish", strings.NewReader(""))
|
||||||
|
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 unfinish status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body = rr.Body.String()
|
||||||
|
if !strings.Contains(body, `id="detail-meta"`) {
|
||||||
|
t.Errorf("unfinish answer is not the meta fragment:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `<span class="mark">finished `) {
|
||||||
|
t.Errorf("unfinish answer still carries the finished mark:\n%s", body)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'solo'`).Scan(&at); err != nil {
|
||||||
|
t.Fatalf("read finished_at after un-finish: %v", err)
|
||||||
|
}
|
||||||
|
if at != 0 {
|
||||||
|
t.Errorf("finished_at = %d after un-finish, want 0", at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A finished Series shows its state in the list row as a faint mark, and the
|
||||||
|
// row offers no control to set or clear it — the reversal lives on the
|
||||||
|
// detail page, its press, and the mark is the whole of the row's share
|
||||||
|
// (#158).
|
||||||
|
func TestAdminSeriesListShowsFinished(t *testing.T) {
|
||||||
|
router, st := newWebTestServer(t, testConfig())
|
||||||
|
seed(t, st, store.Bookmark{
|
||||||
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
|
Title: "Solo", Kind: store.KindManga,
|
||||||
|
})
|
||||||
|
if err := st.SetSeriesFinished("asura", "solo", time.Now().Add(-2*time.Minute).UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("SetSeriesFinished: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := adminSeriesPage(t, router, st, "")
|
||||||
|
if !strings.Contains(body, `<span class="mark mark-faint">finished</span>`) {
|
||||||
|
t.Errorf("list row lacks the finished mark:\n%s", body)
|
||||||
|
}
|
||||||
|
for _, banned := range []string{"solo/finish", "solo/unfinish"} {
|
||||||
|
if strings.Contains(body, banned) {
|
||||||
|
t.Errorf("list row offers %q:\n%s", banned, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The series URL repair validates with the poller's own fetch gate and
|
// The series URL repair validates with the poller's own fetch gate and
|
||||||
// answers 400 before anything reaches the store; a URL that passes the gate
|
// answers 400 before anything reaches the store; a URL that passes the gate
|
||||||
// is stored where an Upsert would have ignored it. The request performs no
|
// is stored where an Upsert would have ignored it. The request performs no
|
||||||
|
|||||||
Reference in New Issue
Block a user