Implements spec #135 (spec 2 of 4, derived from wayfinder map #114; decisions settled in #120/#121/#125/#131). Blocked-by #134 is merged, so this lands on `main`. Four owner actions the dashboard can now perform, one ticket each: - **#149** — Latest Chapter correction: one numeric input, overwritten by the next machine write. - **#151** — Series URL repair: owner-typed, gated by the poller's own fetch gate. - **#150 / #153 / #154** — Cover replacement: addresses derived from bytes (`#150`), a Forced Poll replaces the Cover while an ordinary pass still only fills a blank one (`#153`), and byte reclamation is one guarded helper, file first / covers row last (`#154`). - **#155** — Orphan removal: one Series at a time, with the foreign key as the guard. Plus **#152** — Latest Chapter provenance: one derived line naming the actor class, so an owner can tell a hand-edited number from a machine read. - Migration `0015_latest_correction.sql` adds the correction/provenance columns; `0009` now derives cover addresses from bytes. - ADR `0014-cover-addresses-from-bytes.md` records the address scheme. Backend tests cover the store, poller, admin handlers, and web routes (`go test ./...`, needs Docker). Reviewed-on: #156 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #156.
This commit is contained in:
@@ -24,14 +24,26 @@ type seriesDetailView struct {
|
||||
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 —
|
||||
@@ -40,6 +52,10 @@ type seriesDetailView struct {
|
||||
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
|
||||
}
|
||||
|
||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||
@@ -103,12 +119,14 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
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,
|
||||
}
|
||||
@@ -122,5 +140,34 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
} else {
|
||||
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
||||
}
|
||||
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user