e93e79c1bb
Implements spec #137 (spec 4 of 4 from wayfinder map #114). Closes #137. Tickets: #164, #165, #166, #167, #168, #169, #170, #171, #172, #173 — all closed, landed on this branch. ## Summary - #164/#168: sixth outcome word `not_found`; per-Site completed marker predicate. - #165/#169: `poll_failures` row is the failure state; the pass remembers a Site-reported completion. - #166/#171: two new admin filters (`failing`, `unverified`); outbound owner notification + stall condition. - #167/#170: a failure names itself on the Series page; the completion hint reaches the owner and decides nothing. - #172: the other three fault conditions (no-browser-route, sidecar-down, adapter-broken) feeding the notifier. - #173: the landing verdict line shares the same `latest.FaultsFrom` judgement the notifier uses, so the page and the push cannot disagree. `cd backend && go test ./...` green on the merged branch (8 packages). Reviewed-on: #174 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
214 lines
8.5 KiB
Go
214 lines
8.5 KiB
Go
package web
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// seriesDetailView is one Series' page as the owner sees it: strings and
|
|
// flags, every judgement made here, the template left to print. ReaderCount
|
|
// is the only figure that crosses the privacy boundary — the owner learns how
|
|
// many Readers hold the Series, never which Reader reads what.
|
|
type seriesDetailView struct {
|
|
Key string // "<site>:<series_id>", the page's address and the Series' identity
|
|
Site string
|
|
Kind string
|
|
// Title, Cover and Chapter come from the shared Series row; the Cover is
|
|
// the wire URL of the stored bytes, "" before any exist.
|
|
Title string
|
|
Cover string
|
|
Chapter string // Latest Chapter number, or "—" before the first capture
|
|
Checked string // how long ago the poller last checked, or "never"
|
|
// URL is the stored source address, prefilled into the repair input —
|
|
// the one stored string this page renders back into a form (issue #151).
|
|
URL string
|
|
Readers int
|
|
// 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
|
|
|
|
// Provenance is the actor class behind the current Chapter: "correction",
|
|
// "sighting" or "machine read"; "" while the Series was never read, when
|
|
// the line is not rendered. Derived from the same anonymous stamps the
|
|
// marks above read — no Reader identity crosses here.
|
|
Provenance string
|
|
|
|
// Marks, one per hygiene fact, rendered only while it holds.
|
|
Unpollable bool // no SeriesURL to fetch
|
|
NoCover bool
|
|
Orphan bool // no Reader holds the Series
|
|
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
|
// 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
|
|
// Series with no page to fetch and on an orphan; Pending is derived —
|
|
// the request stamp is newer than the check stamp — and Requested is its
|
|
// ageing label.
|
|
CanPoll bool
|
|
Pending bool
|
|
Requested string
|
|
// CanRemove is the Remove control's visibility (issue #155): only a
|
|
// Series no Reader holds can be removed, so the owner is never offered a
|
|
// button the database will always refuse.
|
|
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
|
|
// Unverified is the sentence beside the Latest Chapter correction
|
|
// control while a Reader's number stands behind a failure past the
|
|
// owner window: the value is unconfirmed and the owner should not trust
|
|
// it. It never names the Reader. "" otherwise.
|
|
Unverified string
|
|
// SiteCompleted is the hint's line, "" while the Site has said nothing or
|
|
// the owner has finished the Series: "the site says this work is
|
|
// completed (since 3d ago)". A hint, never a control (issue #170).
|
|
SiteCompleted string
|
|
}
|
|
|
|
// adminSeriesDetail renders one Series' page, keyed by the composite
|
|
// "<site>:<series_id>" the list row already shows. The row is read through
|
|
// the list's own SeriesPage read narrowed to the key's Site: the admin
|
|
// projection is the privacy boundary, and a dedicated single-row read would
|
|
// be a second definition of it.
|
|
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
|
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
|
if !ok || site == "" || seriesID == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
a, found, err := h.adminSeriesByKey(site, seriesID)
|
|
if err != nil {
|
|
log.Printf("series detail %s: %v", site+":"+seriesID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !found {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(a)})
|
|
}
|
|
|
|
// adminSeriesByKey reads one Series through the list's own SeriesPage read
|
|
// narrowed to the key's Site: the admin projection is the privacy boundary,
|
|
// and a dedicated single-row read would be a second definition of it. Absence
|
|
// is reported with found=false, never an error.
|
|
// ponytail: a page scan per keyed read, one query per page of the Site's rows
|
|
// up to the window total; a keyed read alongside SeriesPage when the library
|
|
// outgrows the page size.
|
|
func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bool, error) {
|
|
seen := 0
|
|
for page := 1; ; page++ {
|
|
p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page})
|
|
if err != nil {
|
|
return store.AdminSeries{}, false, err
|
|
}
|
|
seen += len(p.Rows)
|
|
for i := range p.Rows {
|
|
if p.Rows[i].SeriesID == seriesID {
|
|
return p.Rows[i], true, nil
|
|
}
|
|
}
|
|
if seen >= p.Total {
|
|
break
|
|
}
|
|
}
|
|
return store.AdminSeries{}, false, nil
|
|
}
|
|
|
|
// seriesDetailView shapes one AdminSeries row for display: every judgement in
|
|
// Go, the template left to print strings and flags.
|
|
func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
|
canPoll, pending, requested := pollState(a, time.Now())
|
|
v := seriesDetailView{
|
|
Key: a.Key(),
|
|
Site: a.Site,
|
|
Kind: a.Kind,
|
|
Title: a.Title,
|
|
Cover: h.store.CoverWireURL(a.CoverAddress),
|
|
URL: a.SeriesURL,
|
|
Readers: a.ReaderCount,
|
|
Unpollable: a.SeriesURL == "",
|
|
NoCover: a.CoverAddress == "",
|
|
Orphan: a.ReaderCount == 0,
|
|
SightingRaised: a.RaisedByReader,
|
|
CanPoll: canPoll,
|
|
CanRemove: a.ReaderCount == 0,
|
|
Pending: pending,
|
|
Requested: requested,
|
|
}
|
|
if a.LatestChapterNum == nil {
|
|
v.Chapter = "—"
|
|
} else {
|
|
v.Chapter = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
|
}
|
|
if a.LatestCheckedAt == 0 {
|
|
v.Checked = "never"
|
|
} else {
|
|
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
|
}
|
|
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
|
v.Finished = a.FinishedAt != 0
|
|
v.FinishedSince = finishedAge(time.Now(), a.FinishedAt)
|
|
// Unverified: a Reader's number standing behind a failure that has outlived
|
|
// the owner window is a number nobody has re-checked since — say so next to
|
|
// the correction control, without naming the Reader. A machine read or a
|
|
// failure still inside the window carries no sentence; the failure itself
|
|
// has not yet outlived the twelve hours' worth of trust.
|
|
if a.RaisedByReader && a.FailureOutcome != "" && a.FailingSince < time.Now().Add(-ownerWindow).UnixMilli() {
|
|
v.Unverified = "Unverified Reader number: the page has been failing for over 12h, so this Reader-reported chapter is unconfirmed."
|
|
}
|
|
if a.SiteCompletedAt != 0 && a.FinishedAt == 0 {
|
|
v.SiteCompleted = "the site says this work is completed (since " + checkedAge(time.Now(), a.SiteCompletedAt) + ")"
|
|
}
|
|
|
|
// Provenance: the actor class behind the current number, evaluated in the
|
|
// order the classes outrank one another — the owner's stamp, which a
|
|
// Correction leaves standing and a machine write clears (issue #149); a
|
|
// raising Reader, which a Correction drops; then any check stamp at all.
|
|
// An Acquisition reads as a machine read because it stamps
|
|
// latest_checked_at exactly as a Poll does, so the two are
|
|
// indistinguishable the moment it finishes; telling them apart would need
|
|
// the column this project declines to add (spec #135), and the one
|
|
// actionable case — acquired once, never read again — is already the
|
|
// unchecked filter.
|
|
if a.LatestCorrectedAt != 0 {
|
|
v.Provenance = "correction"
|
|
} else if a.RaisedByReader {
|
|
v.Provenance = "sighting"
|
|
} else if a.LatestCheckedAt != 0 {
|
|
v.Provenance = "machine read"
|
|
}
|
|
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))
|
|
}
|
|
|
|
// 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))
|
|
}
|