Spec #135: owner data-correction actions — Latest Chapter, series_url, Cover, orphan removal (#156)

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:
2026-08-22 12:10:40 +07:00
committed by sulthan
parent 889f0f3f38
commit 4aaf1d4f91
25 changed files with 2327 additions and 130 deletions
@@ -1,8 +1,9 @@
{{/* 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
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
with the other marks. */}}
the anonymous Reader count. Check now lands in its own .dform below the
.detail-grid; the correction form is the grid's first column and the URL
repair the second (issue #151). The pending and corrected markers ride the
meta line with the other marks. */}}
{{define "series-detail"}}
<a class="ghost detail-back" href="/admin/series">← Series</a>
<h1 class="detail-title">{{.Title}}</h1>
@@ -10,26 +11,52 @@
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
{{else}}<div class="cover"></div>{{end}}
{{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>
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML">
<h3>Repair series URL</h3>
<p class="hint">The Poll fetches this address — storing is not verifying it. A Site-wide host change is a SQL migration, not two hundred forms.</p>
<div class="field">
<input type="url" name="series_url" value="{{.URL}}" required>
<button type="submit" class="ghost">Set</button>
</div>
</form>
</div>
{{if .CanPoll}}
<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>
{{end}}
{{if .CanRemove}}
<div class="dform">
<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>
{{end}}
{{end}}
{{/* series-detail-meta is the meta line, and the answer a Check now press on
the detail page swaps into its place: the same marks, re-rendered after
the stamp so the pending marker shows. */}}
{{/* series-detail-meta is the meta line, and the answer a Check now or
correction press on the detail page swaps into its place: the same marks,
re-rendered after the stamp so the pending and corrected markers — and
the provenance line beside the number — describe the value they sit
next to. */}}
{{define "series-detail-meta"}}
<div class="detail-meta" id="detail-meta">
<span>ch {{.Chapter}}</span>
{{if .Provenance}}<span>{{.Provenance}}</span>{{end}}
<span>checked {{.Checked}}</span>
<span>{{.Readers}} readers</span>
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
</div>
{{end}}
{{end}}
@@ -4,7 +4,7 @@
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"}}
<form class="filterbar" method="get" action="/admin/series">
<form class="filterbar" id="filterbar" method="get" action="/admin/series">
<input type="hidden" name="kind" value="{{.Kind}}">
<label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()">
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
@@ -19,7 +19,7 @@
<a href="{{.KindNovel}}"{{if eq .Kind "novel"}} class="active"{{end}}>novels</a>
</span>
</form>
<div class="listhead">{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
{{template "series-list-head" .}}
{{if .Rows}}
<div class="tbl series">
<div class="thead"><span>Site</span><span class="c-ch">Ch</span><span>Checked</span><span class="c-rd">Readers</span><span>Notes</span><span></span></div>
@@ -48,6 +48,16 @@
<span>{{.Age}}</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-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}}</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}}
</div>
{{end}}
{{/* series-list-head is the list's heading — the count and the label are one
fact. The removal answer renders it out of band (the OOB flag, like the
chrome partials) so the heading never lies past the row that made it,
and inline here it is the page's own heading. The id is the OOB swap's
hook; hx-swap-oob sits on the element the answer carries. */}}
{{define "series-list-head"}}
<div class="listhead" id="series-listhead"{{if .OOB}} hx-swap-oob="true"{{end}}>{{.Total}} series <span class="lbl">· <em>{{.FilterLabel}}</em></span></div>
{{end}}