@@ -54,6 +54,9 @@ type Bookmark struct {
|
||||
// Kind is the library bucket: manga or novel. Empty on the way in means
|
||||
// "no opinion" — see Upsert.
|
||||
Kind string `json:"kind"`
|
||||
// Finished is the owner's retirement of the Series, derived: the flag is a
|
||||
// Series fact and a client cannot write it — see Upsert.
|
||||
Finished bool `json:"finished"`
|
||||
}
|
||||
|
||||
// Series is one distinct work, shared by every bookmark that tracks it. It is
|
||||
@@ -230,7 +233,8 @@ var migrations embed.FS
|
||||
// order, so the flat Bookmark reads back whole despite the split (ADR-0004).
|
||||
const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_address,
|
||||
b.last_chapter, b.last_chapter_num, b.last_chapter_url,
|
||||
b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind`
|
||||
b.favorite, s.latest_chapter, s.latest_chapter_num, b.updated_at, b.status, s.kind,
|
||||
s.finished_at > 0`
|
||||
|
||||
// seriesColumns is the series row in scanSeries order, used by the poller's
|
||||
// due query. latest_checked_at lives only on series — see MarkLatestChecked
|
||||
@@ -609,6 +613,7 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
&b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &coverAddress,
|
||||
&b.LastChapter, &b.LastChapterNum, &b.LastChapterURL,
|
||||
&b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind,
|
||||
&b.Finished,
|
||||
); err != nil {
|
||||
return Bookmark{}, err
|
||||
}
|
||||
|
||||
@@ -934,6 +934,37 @@ func TestUpsertCreatesSeriesFromClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The wire's finished flag is derived, never stored: the Upsert's explicit
|
||||
// column list does not name finished_at — the same omission that protects
|
||||
// cover — so a client echoing a cached flag, true or false, cannot change the
|
||||
// Series' retired state (issues #157, #160).
|
||||
func TestUpsertCannotWriteSeriesFinished(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
seedFinished(t, store, "asura:done", "https://asurascans.com/comics/asura:done")
|
||||
|
||||
for _, sent := range []bool{true, false} {
|
||||
stored, err := store.Upsert(store.OwnerID(), Bookmark{
|
||||
Key: "asura:done", Site: "asura", SeriesID: "done",
|
||||
Finished: sent, UpdatedAt: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert(Finished: %v): %v", sent, err)
|
||||
}
|
||||
if !stored.Finished {
|
||||
t.Fatalf("stored.Finished = false after echoing %v, want true", sent)
|
||||
}
|
||||
}
|
||||
var finishedAt int64
|
||||
if err := store.db.QueryRow(
|
||||
`SELECT finished_at FROM series WHERE site = $1 AND series_id = $2`,
|
||||
"asura", "done").Scan(&finishedAt); err != nil {
|
||||
t.Fatalf("read finished_at: %v", err)
|
||||
}
|
||||
if finishedAt != 1000 {
|
||||
t.Fatalf("series.finished_at = %v, want the seeded 1000 untouched", finishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// The hook is what starts creation-time acquisition, so it must fire exactly
|
||||
// once per Series — on the PUT that created it, and on no later one, whichever
|
||||
// Reader sends it.
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
/* One accent per action, so a press says which lane it belongs to. All three
|
||||
are held at the same weight as --brass: muted, no ember competition. */
|
||||
--slate: #7fa0c0; /* archive */
|
||||
--moss: #7fae86; /* finished */
|
||||
--clay: #b5906f; /* set chapter */
|
||||
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
|
||||
/* A Lane needing attention: the admin page's only accent. Verdigris — cool,
|
||||
@@ -147,6 +148,7 @@
|
||||
--danger-soft: #7c2c22;
|
||||
--brass: #8a681c;
|
||||
--slate: #3f6689;
|
||||
--moss: #3d6c46;
|
||||
--clay: #7c5533;
|
||||
--trash: #8c6558;
|
||||
--patina: #1f6f66;
|
||||
@@ -548,6 +550,7 @@ button { cursor: pointer; }
|
||||
.new-chapter { color: var(--ember); }
|
||||
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
|
||||
.state svg { width: 10px; height: 10px; }
|
||||
.state.finished { color: var(--moss); }
|
||||
.is-dim .meta { color: var(--mute-2); }
|
||||
.is-dim .site-asura, .is-dim .site-demonic,
|
||||
.is-dim .site-comix, .is-dim .site-kagane,
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
<span class="sep">/</span>
|
||||
<span class="state">archived</span>
|
||||
{{end}}
|
||||
{{if .Finished}}
|
||||
<span class="sep">/</span>
|
||||
<span class="state finished">finished</span>
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
|
||||
Reference in New Issue
Block a user