finished_at lands on series (0016 seeds it from the pre-flip finished bookmarks, then flips those bookmarks to archived), the poll gate and eligible count read the flag instead of a per-Reader vote, PUT rejects the finished status like any unknown value, and the web UI drops the Finished tab, badge and finish button. Novel merge rank is archived > reading. Per-Reader disagreement (one Reader keeps a finished Series in reading forever) is what the flag repairs; the cutover keeps polling state unchanged for every Series.
This commit is contained in:
+5
-3
@@ -95,7 +95,7 @@ _Avoid_: run, cycle, tick, batch, poll history
|
|||||||
**Forced Poll**:
|
**Forced Poll**:
|
||||||
A Poll the owner asks for by hand instead of waiting for the Series's turn. It jumps its
|
A Poll the owner asks for by hand instead of waiting for the Series's turn. It jumps its
|
||||||
Lane's queue and ignores every waiting rule — the rest between Polls, a Sighting standing
|
Lane's queue and ignores every waiting rule — the rest between Polls, a Sighting standing
|
||||||
in for a check, a Series only finished Readers hold — but never overrules a Site that is
|
in for a check, a finished Series — but never overrules a Site that is
|
||||||
refusing us, the Lane's spacing between fetches, or a Series with no page to fetch. Asked
|
refusing us, the Lane's spacing between fetches, or a Series with no page to fetch. Asked
|
||||||
for by marking the Series, never by commanding the poller, so it happens on the Lane's
|
for by marking the Series, never by commanding the poller, so it happens on the Lane's
|
||||||
next pass rather than at the moment of asking.
|
next pass rather than at the moment of asking.
|
||||||
@@ -149,8 +149,10 @@ accent is permitted to signal.
|
|||||||
_Avoid_: unread, update available
|
_Avoid_: unread, update available
|
||||||
|
|
||||||
**Lifecycle bucket**:
|
**Lifecycle bucket**:
|
||||||
Which of three mutually exclusive states a Bookmark sits in — reading, archived, or
|
Which of the two states a Bookmark sits in — reading or archived. A Bookmark is in exactly
|
||||||
finished. A Bookmark is in exactly one. Orthogonal to being a favourite.
|
one. Orthogonal to being a favourite. Finished is not a bucket: it is a fact about the
|
||||||
|
Series (see `series.finished_at`), owned by the owner and stamped once, and every Bookmark
|
||||||
|
on a finished Series is archived.
|
||||||
_Avoid_: state, status (as a domain word), list
|
_Avoid_: state, status (as a domain word), list
|
||||||
|
|
||||||
**Favourite**:
|
**Favourite**:
|
||||||
|
|||||||
+9
-5
@@ -200,12 +200,16 @@ stored** and clients must adopt that response rather than their own payload.
|
|||||||
|
|
||||||
### Lifecycle buckets — `status` on each bookmark
|
### Lifecycle buckets — `status` on each bookmark
|
||||||
|
|
||||||
`reading` | `archived` | `finished`, orthogonal to `favorite`. Archived and
|
`reading` | `archived`, orthogonal to `favorite`. Archived rows appear only
|
||||||
finished appear only in their own tab, never in All, Updated, Favourites or the
|
in their own tab, never in All, Updated, Favourites or the recent strip. The
|
||||||
recent strip. The poller keeps checking archived series and skips finished ones.
|
poller keeps checking archived series; a finished Series (issue #157) is a
|
||||||
|
`series.finished_at` fact the Lane gate reads, with every bookmark on it
|
||||||
|
archived.
|
||||||
|
|
||||||
- `finished` is settable only from the web UI; `PUT /bookmarks/{key}` rejects
|
- `PUT /bookmarks/{key}` accepts only the two values; anything else —
|
||||||
it with 400.
|
`finished` included — is a plain 400, and the web UI's own status control
|
||||||
|
validates the same way. The 0016 migration is the only writer of the flag
|
||||||
|
today; the undo is writing 0.
|
||||||
- **An empty incoming status means "keep the stored one"**, and it is resolved
|
- **An empty incoming status means "keep the stored one"**, and it is resolved
|
||||||
on the `VALUES` side of `Store.Upsert`, not in the conflict clause:
|
on the `VALUES` side of `Store.Upsert`, not in the conflict clause:
|
||||||
`excluded.*` is the post-evaluation row, so a default applied there would
|
`excluded.*` is the post-evaluation row, so a default applied there would
|
||||||
|
|||||||
@@ -73,14 +73,11 @@ func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An empty status is "no opinion" and Upsert keeps the stored bucket.
|
// An empty status is "no opinion" and Upsert keeps the stored bucket.
|
||||||
// Finishing a series is a web-UI decision, so the JSON API refuses it
|
// Anything else outside the two lifecycle buckets is a client bug, not
|
||||||
// rather than trusting every client to leave it alone.
|
// something to silently coerce — finished included, which is no longer a
|
||||||
|
// bucket at all (issue #157).
|
||||||
switch b.Status {
|
switch b.Status {
|
||||||
case "", store.StatusReading, store.StatusArchived:
|
case "", store.StatusReading, store.StatusArchived:
|
||||||
case store.StatusFinished:
|
|
||||||
http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI",
|
|
||||||
http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -157,9 +157,9 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
|||||||
// SeriesPage returns one page of the Series matching the filter, least
|
// SeriesPage returns one page of the Series matching the filter, least
|
||||||
// recently checked first. The LEFT JOIN to Bookmarks is what surfaces the
|
// recently checked first. The LEFT JOIN to Bookmarks is what surfaces the
|
||||||
// orphans that hygiene has to find — an inner join would hide them, exactly
|
// orphans that hygiene has to find — an inner join would hide them, exactly
|
||||||
// as the Lane's join does. ReaderCount is a plain count of every Bookmark on
|
// as the Lane's join does. Every bookmark keeps its Series polled now that
|
||||||
// the Series, which knowingly disagrees with the two Lane queries for as long
|
// finished is a Series flag, so this plain ReaderCount agrees with the Lane
|
||||||
// as the finished lifecycle bucket exists (#140).
|
// queries (issue #157).
|
||||||
//
|
//
|
||||||
// The tie-break is mandatory, not decorative: every unpollable Series shares a
|
// The tie-break is mandatory, not decorative: every unpollable Series shares a
|
||||||
// zero check stamp, so ordering on that column alone gives no stable page
|
// zero check stamp, so ordering on that column alone gives no stable page
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- finished_at is "the owner marked this Series finished" (#157): epoch ms,
|
||||||
|
-- zero means not finished, and it doubles as the undo (write zero). The poll
|
||||||
|
-- gate reads it — a Series is polled only while finished_at = 0 — never a
|
||||||
|
-- bookmark's status.
|
||||||
|
ALTER TABLE series ADD COLUMN finished_at bigint NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Column first, seed second, flip third — the order is load-bearing: a seed
|
||||||
|
-- that ran after the flip would read the buckets it just destroyed, declare
|
||||||
|
-- nothing finished, and silently resume polling on Series nobody chose to
|
||||||
|
-- resume. The seed mirrors the pre-cutover due gate exactly: a Series stays
|
||||||
|
-- polled while any bookmark is outside the finished bucket, so a Series whose
|
||||||
|
-- every bookmark sits in it is stamped, one click from being read again
|
||||||
|
-- afterwards. The stamp is the only memory of the bucket the flip is about to
|
||||||
|
-- erase.
|
||||||
|
UPDATE series s SET finished_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
|
||||||
|
WHERE EXISTS (SELECT 1 FROM bookmarks b
|
||||||
|
WHERE b.site = s.site AND b.series_id = s.series_id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bookmarks b
|
||||||
|
WHERE b.site = s.site AND b.series_id = s.series_id
|
||||||
|
AND b.status <> 'finished');
|
||||||
|
|
||||||
|
-- The Lifecycle bucket is gone; a finished bookmark is an archived one. The
|
||||||
|
-- flip must come after the seed, which still reads the bucket.
|
||||||
|
UPDATE bookmarks SET status = 'archived' WHERE status = 'finished';
|
||||||
@@ -47,8 +47,8 @@ type Bookmark struct {
|
|||||||
LatestChapter string `json:"latest_chapter"`
|
LatestChapter string `json:"latest_chapter"`
|
||||||
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
|
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
|
||||||
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
|
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
|
||||||
// Status is the lifecycle bucket: reading, archived, or finished.
|
// Status is the lifecycle bucket: reading or archived.
|
||||||
// Archived series stay polled for new chapters; finished ones do not.
|
// Archived series stay polled for new chapters.
|
||||||
// Empty on the way in means "no opinion" — see Upsert.
|
// Empty on the way in means "no opinion" — see Upsert.
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
// Kind is the library bucket: manga or novel. Empty on the way in means
|
// Kind is the library bucket: manga or novel. Empty on the way in means
|
||||||
@@ -214,10 +214,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
||||||
|
// Finished is not a bucket: it is a fact about the Series (series.finished_at),
|
||||||
|
// never about a Reader's bookmark.
|
||||||
const (
|
const (
|
||||||
StatusReading = "reading"
|
StatusReading = "reading"
|
||||||
StatusArchived = "archived"
|
StatusArchived = "archived"
|
||||||
StatusFinished = "finished"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
@@ -619,9 +620,9 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
|
|||||||
// bookmark is keyed (reader_id, site, series_id) (issue #22).
|
// bookmark is keyed (reader_id, site, series_id) (issue #22).
|
||||||
b.Key = b.Site + ":" + b.SeriesID
|
b.Key = b.Site + ":" + b.SeriesID
|
||||||
// An unrecognised bucket (a hand-edited row) would leave the row in no list
|
// An unrecognised bucket (a hand-edited row) would leave the row in no list
|
||||||
// at all, so anything outside the three known buckets reads as the default
|
// at all, so anything outside the two known buckets reads as the default
|
||||||
// rather than being passed through.
|
// rather than being passed through.
|
||||||
if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished {
|
if b.Status != StatusReading && b.Status != StatusArchived {
|
||||||
b.Status = StatusReading
|
b.Status = StatusReading
|
||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
@@ -1302,10 +1303,12 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
|||||||
//
|
//
|
||||||
// A forced Series (force_poll_at newer than latest_checked_at, issue #146)
|
// A forced Series (force_poll_at newer than latest_checked_at, issue #146)
|
||||||
// overrides exactly three gates: the rest cutoff, the Sighting-deferral
|
// overrides exactly three gates: the rest cutoff, the Sighting-deferral
|
||||||
// clause and the finished-only bucket. It never overrides an empty
|
// clause and a finished Series. It never overrides an empty series_url or the
|
||||||
// series_url or the Bookmarks join — nothing to fetch, and no consumer for
|
// Bookmarks join — nothing to fetch, and no consumer for the result — so
|
||||||
// the result — so those stay unconditional. Forced rows sort to the front of
|
// those stay unconditional, and it never clears the finish: nothing here
|
||||||
// the queue; the reader-count-then-age ordering among the rest is ADR-0003.
|
// writes finished_at, and pending force clears itself when the pass stamps
|
||||||
|
// the check timestamp. Forced rows sort to the front of the queue; the
|
||||||
|
// reader-count-then-age ordering among the rest is ADR-0003.
|
||||||
//
|
//
|
||||||
// The reader_count ordering is the point of the split (ADR-0003): a series
|
// The reader_count ordering is the point of the split (ADR-0003): a series
|
||||||
// shared by several readers is fetched once per due cycle, and the popular
|
// shared by several readers is fetched once per due cycle, and the popular
|
||||||
@@ -1313,14 +1316,15 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
|||||||
// reader count, oldest-first keeps the poll fair when the backlog outgrows
|
// reader count, oldest-first keeps the poll fair when the backlog outgrows
|
||||||
// throughput: the most neglected series is always next, so a large collection
|
// throughput: the most neglected series is always next, so a large collection
|
||||||
// refreshes uniformly slower rather than leaving a tail that never refreshes
|
// refreshes uniformly slower rather than leaving a tail that never refreshes
|
||||||
// at all. The userscript sorts its own queue the same way (L453).
|
// at all. The userscript sorts its own queue the same way.
|
||||||
//
|
//
|
||||||
// Series with no series_url are skipped — there is nothing to fetch, which is
|
// Series with no series_url are skipped — there is nothing to fetch, which is
|
||||||
// the same filter the userscript applies at L452. Series whose only bookmarks
|
// the same filter the userscript applies before refreshing. A finished Series is
|
||||||
// are finished are skipped too: nothing more is coming, so fetching them only
|
// skipped unless forced: nothing more is coming, so fetching it only burns
|
||||||
// burns requests. Archived bookmarks still count — knowing what a shelved
|
// requests (issue #157). Archived bookmarks still count — knowing what a
|
||||||
// series is up to is the whole reason for archiving instead of deleting.
|
// shelved series is up to is the whole reason for archiving instead of
|
||||||
// A series with no bookmarks at all never appears: the join excludes it.
|
// deleting. A series with no bookmarks at all never appears: the join
|
||||||
|
// excludes it.
|
||||||
//
|
//
|
||||||
// ceilingMs is the Sighting deferral ceiling (issue #103): a Series whose last
|
// ceilingMs is the Sighting deferral ceiling (issue #103): a Series whose last
|
||||||
// real Poll is older than it appears however recently it was sighted. That is
|
// real Poll is older than it appears however recently it was sighted. That is
|
||||||
@@ -1329,10 +1333,10 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
|||||||
// decided here, from two facts the query already computes, so a Lane gains no
|
// decided here, from two facts the query already computes, so a Lane gains no
|
||||||
// query per round: a Sighting younger than cutoffMs holds the Series back, but
|
// query per round: a Sighting younger than cutoffMs holds the Series back, but
|
||||||
// only while COUNT(*) is 1. A Series a second Reader bookmarks is Polled on
|
// only while COUNT(*) is 1. A Series a second Reader bookmarks is Polled on
|
||||||
// schedule, so a wrong value the whole guild can see is corrected by a check
|
// the schedule, so a wrong value the guild can see is corrected by a check
|
||||||
// that was never postponed; on a solitary Series the only person a wrong value
|
// that was never postponed; on a solitary Series the only person a wrong
|
||||||
// reaches is the Reader who reported it. Whether the reporting Reader is
|
// value reaches is the Reader who reported it. Whether the reporting Reader
|
||||||
// allowed to defer at all was settled when the Sighting was recorded — see
|
// is allowed to defer at all was settled when the Sighting was recorded — see
|
||||||
// RecordSighting.
|
// RecordSighting.
|
||||||
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
|
func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) {
|
||||||
rows, err := s.db.Query(`SELECT `+seriesColumns+`,
|
rows, err := s.db.Query(`SELECT `+seriesColumns+`,
|
||||||
@@ -1344,12 +1348,11 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
|
|||||||
AND s.series_url <> ''
|
AND s.series_url <> ''
|
||||||
AND (s.latest_checked_at <= $2::bigint
|
AND (s.latest_checked_at <= $2::bigint
|
||||||
OR s.force_poll_at > s.latest_checked_at)
|
OR s.force_poll_at > s.latest_checked_at)
|
||||||
|
AND (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)
|
||||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
|
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
|
||||||
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.force_poll_at, s.finished_at
|
||||||
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
HAVING (COUNT(*) > 1
|
||||||
OR s.force_poll_at > s.latest_checked_at)
|
|
||||||
AND (COUNT(*) > 1
|
|
||||||
OR s.latest_sighted_at <= $2::bigint
|
OR s.latest_sighted_at <= $2::bigint
|
||||||
OR s.latest_checked_at <= $3::bigint
|
OR s.latest_checked_at <= $3::bigint
|
||||||
OR s.force_poll_at > s.latest_checked_at)
|
OR s.force_poll_at > s.latest_checked_at)
|
||||||
@@ -1371,14 +1374,18 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// EligibleSeriesCount returns how many of a Site's Series still have at least
|
// EligibleSeriesCount returns how many of a Site's Series are not finished —
|
||||||
// one bookmark outside the finished bucket. It is the denominator of the
|
// the flag, never a Reader vote. It is the denominator of the Lane's pace
|
||||||
// Lane's pace (issue #100): the effective gap is the smaller of the registry
|
// (issue #100): the effective gap is the smaller of the registry gap and one
|
||||||
// gap and one hour divided by this count, so Series that will never be Polled
|
// hour divided by this count, so Series that will never be Polled do not make
|
||||||
// do not make the Lane faster than it needs to be, and counting every eligible
|
// the Lane faster than it needs to be, and counting every eligible Series
|
||||||
// Series rather than only those currently due keeps the pace steady — the
|
// rather than only those currently due keeps the pace steady — the single
|
||||||
// single worst moment to be fastest is startup, when everything is due at
|
// worst moment to be fastest is startup, when everything is due at once.
|
||||||
// once.
|
//
|
||||||
|
// The deliberate asymmetry with DueForLatestCheck's WHERE: a forced Series
|
||||||
|
// is due but never admitted here, because a forced pass must not speed up
|
||||||
|
// every other fetch on the Site — one impassioned press is not a reason to
|
||||||
|
// hammer the Site (issue #157).
|
||||||
func (s *Store) EligibleSeriesCount(site string) (int, error) {
|
func (s *Store) EligibleSeriesCount(site string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM (
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM (
|
||||||
@@ -1386,8 +1393,8 @@ func (s *Store) EligibleSeriesCount(site string) (int, error) {
|
|||||||
FROM series s
|
FROM series s
|
||||||
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||||
WHERE s.site = $1
|
WHERE s.site = $1
|
||||||
|
AND s.finished_at = 0
|
||||||
GROUP BY s.site, s.series_id
|
GROUP BY s.site, s.series_id
|
||||||
HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
|
|
||||||
) e`, site).Scan(&n)
|
) e`, site).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count eligible series %s: %w", site, err)
|
return 0, fmt.Errorf("count eligible series %s: %w", site, err)
|
||||||
|
|||||||
@@ -279,6 +279,27 @@ func seedForCheck(t *testing.T, s *Store, key, seriesURL string, checkedAt int64
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedFinished inserts a bookmark (and with it its series) and stamps the
|
||||||
|
// series finished — the series-level fact the due gate reads (issue #157).
|
||||||
|
func seedFinished(t *testing.T, s *Store, key, seriesURL string) {
|
||||||
|
t.Helper()
|
||||||
|
site, seriesID, ok := strings.Cut(key, ":")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("key %q: no ':' separator", key)
|
||||||
|
}
|
||||||
|
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
||||||
|
Key: key, Site: site, SeriesID: seriesID, SeriesURL: seriesURL,
|
||||||
|
UpdatedAt: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed %q: %v", key, err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(
|
||||||
|
`UPDATE series SET finished_at = 1000 WHERE site = $1 AND series_id = $2`,
|
||||||
|
site, seriesID); err != nil {
|
||||||
|
t.Fatalf("seed finish %q: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// noCeiling is a Sighting deferral ceiling no Series can reach, for the tests
|
// noCeiling is a Sighting deferral ceiling no Series can reach, for the tests
|
||||||
// that predate the ceiling and are about rest, ordering or buckets instead.
|
// that predate the ceiling and are about rest, ordering or buckets instead.
|
||||||
const noCeiling = int64(-1)
|
const noCeiling = int64(-1)
|
||||||
@@ -486,24 +507,15 @@ func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
|
|||||||
t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt)
|
t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archiving is the reason to keep polling — the point is to come back to a
|
// Archiving is the reason to keep polling — the point is to come back to a
|
||||||
// series that has moved on. A finished series has nothing left to publish.
|
// series that has moved on. A finished series has nothing left to publish,
|
||||||
|
// whichever Reader marked it (issue #157).
|
||||||
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
for _, tc := range []struct{ key, status string }{
|
for _, key := range []string{"asura:reading", "asura:archived"} {
|
||||||
{"asura:reading", StatusReading},
|
seedForCheck(t, store, key, "https://asurascans.com/comics/"+key, 0)
|
||||||
{"asura:archived", StatusArchived},
|
|
||||||
{"asura:finished", StatusFinished},
|
|
||||||
} {
|
|
||||||
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
||||||
Key: tc.key, Site: "asura", SeriesID: strings.TrimPrefix(tc.key, "asura:"),
|
|
||||||
SeriesURL: "https://asurascans.com/comics/" + tc.key,
|
|
||||||
Status: tc.status, UpdatedAt: time.Now().UnixMilli(),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("seed %s: %v", tc.key, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
seedFinished(t, store, "asura:finished", "https://asurascans.com/comics/asura:finished")
|
||||||
|
|
||||||
due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
|
due, err := store.DueForLatestCheck("asura", time.Now().UnixMilli(), noCeiling)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -522,27 +534,26 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The gap's denominator counts every Series the Lane will ever Poll: a
|
// The gap's denominator counts every Series the Lane will ever Poll: a
|
||||||
// finished Series must not make the Lane faster than it needs to be, and
|
// finished Series must not make the Lane faster than it needs to be, another
|
||||||
// another Site's Series must not leak into this Site's count.
|
// Site's Series must not leak into this Site's count, and a forced Series is
|
||||||
|
// not admitted either — it is due once, not a reason to tighten the pace for
|
||||||
|
// every other fetch on the Site (issue #157).
|
||||||
func TestEligibleSeriesCount(t *testing.T) {
|
func TestEligibleSeriesCount(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
seedForCheck(t, store, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
seedForCheck(t, store, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
||||||
seedForCheck(t, store, "asura:archived", "https://asurascans.com/comics/archived", 0)
|
seedForCheck(t, store, "asura:archived", "https://asurascans.com/comics/archived", 0)
|
||||||
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
seedFinished(t, store, "asura:finished", "https://asurascans.com/comics/asura:finished")
|
||||||
Key: "asura:finished", Site: "asura", SeriesID: "finished",
|
|
||||||
SeriesURL: "https://asurascans.com/comics/finished",
|
|
||||||
Status: StatusFinished, UpdatedAt: 1000,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("seed finished: %v", err)
|
|
||||||
}
|
|
||||||
seedForCheck(t, store, "demonic:z", "https://demonicscans.org/manga/z", 0)
|
seedForCheck(t, store, "demonic:z", "https://demonicscans.org/manga/z", 0)
|
||||||
|
if err := store.ForceSeriesPoll("asura", "archived", 5000); err != nil {
|
||||||
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
n, err := store.EligibleSeriesCount("asura")
|
n, err := store.EligibleSeriesCount("asura")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EligibleSeriesCount: %v", err)
|
t.Fatalf("EligibleSeriesCount: %v", err)
|
||||||
}
|
}
|
||||||
if n != 2 {
|
if n != 2 {
|
||||||
t.Fatalf("eligible = %d, want 2 (finished excluded, demonic excluded)", n)
|
t.Fatalf("eligible = %d, want 2 (finished and forced excluded, demonic excluded)", n)
|
||||||
}
|
}
|
||||||
n, err = store.EligibleSeriesCount("demonic")
|
n, err = store.EligibleSeriesCount("demonic")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -755,6 +766,133 @@ func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0016's three statements are order-dependent: the finished_at seed must see
|
||||||
|
// the pre-flip 'finished' buckets, and the flip must come after it. A seed
|
||||||
|
// that ran after the flip would read bookmarks that no longer say
|
||||||
|
// 'finished', declare nothing finished, and silently resume polling series
|
||||||
|
// somebody marked done. This seeds a pre-0016 database the way every
|
||||||
|
// deployment looked — finished a bookmark bucket, no finished_at column —
|
||||||
|
// runs the migration, and asserts on what it preserved.
|
||||||
|
func TestMigration0016SeedsFinishedAtBeforeFlippingBucket(t *testing.T) {
|
||||||
|
url := pgtest.URL(t)
|
||||||
|
db, err := sql.Open("pgx", url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := migrate(db, 15); err != nil {
|
||||||
|
t.Fatalf("migrate to 0015: %v", err)
|
||||||
|
}
|
||||||
|
if err := seedOwner(db, testOwner); err != nil {
|
||||||
|
t.Fatalf("seed owner: %v", err)
|
||||||
|
}
|
||||||
|
// A second reader, so "mixed" can carry two bookmarks on one series.
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO readers (discord_id, token_sha256) VALUES ('second', '\x01'::bytea)`); err != nil {
|
||||||
|
t.Fatalf("seed second reader: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := func() int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT id FROM readers WHERE discord_id = $1`, testOwner.DiscordID).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("owner id: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}()
|
||||||
|
seedBookmark := func(readerID int64, site, seriesID, status string) {
|
||||||
|
t.Helper()
|
||||||
|
// The bookmark FK demands the series row; Upsert would create it on
|
||||||
|
// the fly, raw SQL has to spell it out.
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO series (site, series_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
site, seriesID); err != nil {
|
||||||
|
t.Fatalf("seed series %s:%s: %v", site, seriesID, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO bookmarks (reader_id, site, series_id, status, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 1000)`, readerID, site, seriesID, status); err != nil {
|
||||||
|
t.Fatalf("seed %s:%s/%s: %v", site, seriesID, status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// done: the one-bookmark series every finished series looked like.
|
||||||
|
seedBookmark(owner, "asura", "done", "finished")
|
||||||
|
// shared: every reader finished — the multi-reader equivalent.
|
||||||
|
seedBookmark(owner, "asura", "shared", "finished")
|
||||||
|
// mixed: a second reader still reading keeps the series alive.
|
||||||
|
seedBookmark(owner, "asura", "mixed", "finished")
|
||||||
|
seedBookmark(owner+1, "asura", "mixed", "archived")
|
||||||
|
// live: nobody finished it.
|
||||||
|
seedBookmark(owner, "asura", "live", "reading")
|
||||||
|
// orphan: no bookmarks at all, nothing to decide from.
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO series (site, series_id) VALUES ('asura', 'orphan')`); err != nil {
|
||||||
|
t.Fatalf("seed orphan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrate(db, 0); err != nil {
|
||||||
|
t.Fatalf("migrate 0016: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finishedAt := func(site, seriesID string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var at int64
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT finished_at FROM series WHERE site = $1 AND series_id = $2`,
|
||||||
|
site, seriesID).Scan(&at); err != nil {
|
||||||
|
t.Fatalf("finished_at %s:%s: %v", site, seriesID, err)
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
status := func(readerID int64, site, seriesID string) string {
|
||||||
|
t.Helper()
|
||||||
|
var s string
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT status FROM bookmarks WHERE reader_id = $1
|
||||||
|
AND site = $2 AND series_id = $3`, readerID, site, seriesID).Scan(&s); err != nil {
|
||||||
|
t.Fatalf("status %s:%s: %v", site, seriesID, err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seed ran before the flip: had the flip gone first, done's bookmarks
|
||||||
|
// would read archived and nothing would be stamped. The stamp is a real
|
||||||
|
// timestamp, not a sentinel.
|
||||||
|
before := time.Now().Add(-time.Minute).UnixMilli()
|
||||||
|
after := time.Now().Add(time.Minute).UnixMilli()
|
||||||
|
for _, sr := range []struct{ site, seriesID string }{
|
||||||
|
{"asura", "done"}, {"asura", "shared"},
|
||||||
|
} {
|
||||||
|
if at := finishedAt(sr.site, sr.seriesID); at < before || at > after {
|
||||||
|
t.Fatalf("%s:%s finished_at = %d, want now-ish", sr.site, sr.seriesID, at)
|
||||||
|
}
|
||||||
|
// The flip followed the seed: every finished bookmark is archived.
|
||||||
|
if s := status(owner, sr.site, sr.seriesID); s != StatusArchived {
|
||||||
|
t.Fatalf("%s:%s status = %q, want archived after the flip", sr.site, sr.seriesID, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The seed ignores a series any bookmark keeps alive.
|
||||||
|
if at := finishedAt("asura", "mixed"); at != 0 {
|
||||||
|
t.Fatalf("mixed finished_at = %d, want 0", at)
|
||||||
|
}
|
||||||
|
// The flip is a bucket rewrite, not a series-wide one: the finished
|
||||||
|
// bookmark becomes archived and the sibling rows keep their buckets.
|
||||||
|
if s := status(owner, "asura", "mixed"); s != StatusArchived {
|
||||||
|
t.Fatalf("mixed finished bookmark = %q, want archived", s)
|
||||||
|
}
|
||||||
|
if s := status(owner+1, "asura", "mixed"); s != StatusArchived {
|
||||||
|
t.Fatalf("mixed archived bookmark = %q, want untouched archived", s)
|
||||||
|
}
|
||||||
|
if s := status(owner, "asura", "live"); s != StatusReading {
|
||||||
|
t.Fatalf("live status = %q, want untouched reading", s)
|
||||||
|
}
|
||||||
|
if at := finishedAt("asura", "orphan"); at != 0 {
|
||||||
|
t.Fatalf("orphan finished_at = %d, want 0", at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// readSeries reads the series row directly, for asserting on what Upsert
|
// readSeries reads the series row directly, for asserting on what Upsert
|
||||||
// actually stored rather than what the joined Bookmark reports.
|
// actually stored rather than what the joined Bookmark reports.
|
||||||
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
||||||
@@ -1809,19 +1947,12 @@ func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The finished-only bucket excludes a series whose only bookmarks are
|
// The finished flag excludes a series; a forced request overrides it — the
|
||||||
// finished; a forced request overrides it — the owner asked, so the Lane
|
// owner asked, so the Lane looks.
|
||||||
// looks.
|
func TestDueForLatestCheckForcedOverridesFinishedFlag(t *testing.T) {
|
||||||
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
||||||
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
seedFinished(t, s, "asura:finished", "https://asurascans.com/comics/asura:finished")
|
||||||
Key: "asura:finished", Site: "asura", SeriesID: "finished",
|
|
||||||
SeriesURL: "https://asurascans.com/comics/finished",
|
|
||||||
Status: StatusFinished, UpdatedAt: 1000,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("seed finished: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function setActiveTab(el) {
|
|||||||
document.dispatchEvent(new Event("bmgr:refilter"));
|
document.dispatchEvent(new Event("bmgr:refilter"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chapter-edit form and the archive/finish/remove confirm rows are the
|
// The chapter-edit form and the archive/remove confirm rows are the
|
||||||
// per-card disclosure panels; only one makes sense open at a time. The button
|
// per-card disclosure panels; only one makes sense open at a time. The button
|
||||||
// that owns an open panel carries .open, which is how the strip shows which
|
// that owns an open panel carries .open, which is how the strip shows which
|
||||||
// cell the panel belongs to.
|
// cell the panel belongs to.
|
||||||
@@ -97,10 +97,10 @@ function toggleChapterForm(key) {
|
|||||||
if (form && !form.hidden) form.querySelector("input").focus();
|
if (form && !form.hidden) form.querySelector("input").focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// kind is "archive" | "finish" | "remove" — the panel id and the owning action
|
// kind is "archive" | "remove" — the panel id and the owning action
|
||||||
// cell share it.
|
// cell share it.
|
||||||
function toggleConfirmRow(key, kind) {
|
function toggleConfirmRow(key, kind) {
|
||||||
var cls = { archive: ".box", finish: ".finish", remove: ".remove" }[kind];
|
var cls = { archive: ".box", remove: ".remove" }[kind];
|
||||||
var row = togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls);
|
var row = togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls);
|
||||||
// Focus the answer rather than trusting aria-live on a container that merely
|
// Focus the answer rather than trusting aria-live on a container that merely
|
||||||
// unhides: it makes the announcement deterministic, keeps tab order inside
|
// unhides: it makes the announcement deterministic, keeps tab order inside
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
--ink: #100f0e; /* page */
|
--ink: #100f0e; /* page */
|
||||||
--ash: #161413; /* recessed panel (chapter form) */
|
--ash: #161413; /* recessed panel (chapter form) */
|
||||||
--dim: #0d0c0b; /* archived / finished rows sink */
|
--dim: #0d0c0b; /* archived rows sink */
|
||||||
--rule: #221f1d; /* hairline between sheets */
|
--rule: #221f1d; /* hairline between sheets */
|
||||||
--rule-soft: #1a1817; /* measure edges */
|
--rule-soft: #1a1817; /* measure edges */
|
||||||
--field-line: #2c2926;
|
--field-line: #2c2926;
|
||||||
@@ -84,7 +84,6 @@
|
|||||||
/* One accent per action, so a press says which lane it belongs to. All three
|
/* 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. */
|
are held at the same weight as --brass: muted, no ember competition. */
|
||||||
--slate: #7fa0c0; /* archive */
|
--slate: #7fa0c0; /* archive */
|
||||||
--moss: #7fae86; /* finished */
|
|
||||||
--clay: #b5906f; /* set chapter */
|
--clay: #b5906f; /* set chapter */
|
||||||
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
|
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
|
||||||
/* A Lane needing attention: the admin page's only accent. Verdigris — cool,
|
/* A Lane needing attention: the admin page's only accent. Verdigris — cool,
|
||||||
@@ -148,7 +147,6 @@
|
|||||||
--danger-soft: #7c2c22;
|
--danger-soft: #7c2c22;
|
||||||
--brass: #8a681c;
|
--brass: #8a681c;
|
||||||
--slate: #3f6689;
|
--slate: #3f6689;
|
||||||
--moss: #3d6c46;
|
|
||||||
--clay: #7c5533;
|
--clay: #7c5533;
|
||||||
--trash: #8c6558;
|
--trash: #8c6558;
|
||||||
--patina: #1f6f66;
|
--patina: #1f6f66;
|
||||||
@@ -612,13 +610,13 @@ button { cursor: pointer; }
|
|||||||
.actions > *:last-child { border-right: none; }
|
.actions > *:last-child { border-right: none; }
|
||||||
.actions svg { width: 17px; height: 17px; }
|
.actions svg { width: 17px; height: 17px; }
|
||||||
.actions > *:hover { color: var(--paper); }
|
.actions > *:hover { color: var(--paper); }
|
||||||
/* Per-action accent on hover and press: gold favourite, slate archive, moss
|
/* Per-action accent on hover and press: gold favourite, slate archive, clay
|
||||||
finished, clay chapter. Remove keeps --danger, play keeps paper/ember. */
|
chapter. Remove keeps --danger, play keeps paper/ember. */
|
||||||
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); }
|
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); }
|
||||||
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); }
|
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); }
|
||||||
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); }
|
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); }
|
||||||
.actions .finish:hover, .actions .finish:active, .actions .finish:focus-visible { color: var(--moss); }
|
|
||||||
.actions .play { color: var(--paper); }
|
.actions .play { color: var(--paper); }
|
||||||
|
|
||||||
.is-new .actions .play { color: var(--ember); }
|
.is-new .actions .play { color: var(--ember); }
|
||||||
.actions .play:hover { background: var(--hover); }
|
.actions .play:hover { background: var(--hover); }
|
||||||
.actions .on { color: var(--brass); }
|
.actions .on { color: var(--brass); }
|
||||||
@@ -632,8 +630,8 @@ button { cursor: pointer; }
|
|||||||
.actions .remove.open { background: var(--danger-wash); color: var(--danger); }
|
.actions .remove.open { background: var(--danger-wash); color: var(--danger); }
|
||||||
.is-dim .actions > * { color: var(--mute-2); }
|
.is-dim .actions > * { color: var(--mute-2); }
|
||||||
|
|
||||||
/* Three clusters by consequence: navigate (play) | organize (favourite,
|
/* Two clusters by consequence: navigate (play) | organize (favourite,
|
||||||
chapter) | lifecycle (archive/restore, finish, remove). The lifecycle cells
|
chapter) | lifecycle (archive/restore, remove). The lifecycle cells
|
||||||
sit on a recessed ground so the thumb reads "this one moves the series"
|
sit on a recessed ground so the thumb reads "this one moves the series"
|
||||||
before it reads which icon it landed on. */
|
before it reads which icon it landed on. */
|
||||||
.actions > .lifecycle { background: var(--ash); }
|
.actions > .lifecycle { background: var(--ash); }
|
||||||
@@ -712,7 +710,7 @@ button { cursor: pointer; }
|
|||||||
color: var(--danger-ink);
|
color: var(--danger-ink);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
/* Archive and finish are reversible, so their confirm asks in grey — only the
|
/* Archive is reversible, so its confirm asks in grey — only the
|
||||||
irreversible remove gets the danger wash. */
|
irreversible remove gets the danger wash. */
|
||||||
.confirm-row.calm { background: var(--ash); }
|
.confirm-row.calm { background: var(--ash); }
|
||||||
.confirm-row.calm span { color: var(--paper-dim); }
|
.confirm-row.calm span { color: var(--paper-dim); }
|
||||||
@@ -917,9 +915,7 @@ button { cursor: pointer; }
|
|||||||
/* The cell border follows the icon on hover, so the accent reads as a state
|
/* The cell border follows the icon on hover, so the accent reads as a state
|
||||||
rather than a stray colour. */
|
rather than a stray colour. */
|
||||||
.actions .fav:hover, .actions .pencil:hover,
|
.actions .fav:hover, .actions .pencil:hover,
|
||||||
.actions .box:hover, .actions .finish:hover { border-color: currentColor; }
|
.actions .box:hover { border-color: currentColor; }
|
||||||
|
|
||||||
/* Panels line up with the body text, i.e. past the cover and its gap. */
|
|
||||||
.chapter-form, .confirm-row, .error-inline {
|
.chapter-form, .confirm-row, .error-inline {
|
||||||
margin-left: calc(var(--cover-w) + var(--row-gap));
|
margin-left: calc(var(--cover-w) + var(--row-gap));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,10 +71,6 @@
|
|||||||
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
||||||
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
|
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
|
||||||
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
|
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||||
<a href="{{.PageURL "finished"}}" class="{{if eq .Tab "finished"}}active{{end}}"
|
|
||||||
{{if eq .Tab "finished"}}aria-current="page"{{end}}
|
|
||||||
hx-get="{{.ListURL "finished"}}" hx-target="#list" hx-swap="innerHTML"
|
|
||||||
hx-push-url="{{.PageURL "finished"}}" hx-on::after-request="setActiveTab(this)">Finished</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{{define "card"}}
|
{{define "card"}}
|
||||||
{{/* One sheet per series. is-new turns the title crimson over an ember rule;
|
{{/* One sheet per series. is-new turns the title crimson over an ember rule;
|
||||||
is-dim sinks archived and finished rows into italic grey. */}}
|
is-dim sinks archived rows into italic grey. */}}
|
||||||
<article class="card{{if eq .Status "reading"}}{{if .HasNewChapter}} is-new{{end}}{{else}} is-dim{{end}}"
|
<article class="card{{if eq .Status "reading"}}{{if .HasNewChapter}} is-new{{end}}{{else}} is-dim{{end}}"
|
||||||
id="card-{{.Key}}" data-title="{{.Title}}">
|
id="card-{{.Key}}" data-title="{{.Title}}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -32,9 +32,6 @@
|
|||||||
{{if eq .Status "archived"}}
|
{{if eq .Status "archived"}}
|
||||||
<span class="sep">/</span>
|
<span class="sep">/</span>
|
||||||
<span class="state">archived</span>
|
<span class="state">archived</span>
|
||||||
{{else if eq .Status "finished"}}
|
|
||||||
<span class="sep">/</span>
|
|
||||||
<span class="state"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>finished</span>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +53,7 @@
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{{/* Restore is a reversal, so it fires straight away; every move *out* of
|
{{/* Restore is a reversal, so it fires straight away; every move *out* of
|
||||||
the list (archive, finish, remove) goes through a confirm row. */}}
|
the list (archive, remove) goes through a confirm row. */}}
|
||||||
{{if eq .Status "reading"}}
|
{{if eq .Status "reading"}}
|
||||||
<button class="lifecycle box" title="Archive" aria-label="Archive"
|
<button class="lifecycle box" title="Archive" aria-label="Archive"
|
||||||
aria-expanded="false" aria-controls="confirm-archive-{{.Key}}"
|
aria-expanded="false" aria-controls="confirm-archive-{{.Key}}"
|
||||||
@@ -71,13 +68,6 @@
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if ne .Status "finished"}}
|
|
||||||
<button class="lifecycle finish" title="Mark finished" aria-label="Mark finished"
|
|
||||||
aria-expanded="false" aria-controls="confirm-finish-{{.Key}}"
|
|
||||||
onclick="toggleConfirmRow('{{.Key}}', 'finish')">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
|
|
||||||
</button>
|
|
||||||
{{end}}
|
|
||||||
<button class="lifecycle remove" title="Remove" aria-label="Remove"
|
<button class="lifecycle remove" title="Remove" aria-label="Remove"
|
||||||
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
|
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
|
||||||
onclick="toggleConfirmRow('{{.Key}}', 'remove')">
|
onclick="toggleConfirmRow('{{.Key}}', 'remove')">
|
||||||
@@ -116,18 +106,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if ne .Status "finished"}}
|
|
||||||
<div class="confirm-row calm" id="confirm-finish-{{.Key}}" role="group" aria-live="polite" hidden>
|
|
||||||
<span>Mark finished?</span>
|
|
||||||
<div>
|
|
||||||
<button class="go"
|
|
||||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
|
|
||||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
|
||||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Finish</button>
|
|
||||||
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'finish')">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
|
<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||||
<span>Remove “{{.Title}}”? Chapter progress is lost.</span>
|
<span>Remove “{{.Title}}”? Chapter progress is lost.</span>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -29,21 +29,17 @@
|
|||||||
|
|
||||||
{{/* The action key. The icon strip on a card is unlabelled, so one permanent
|
{{/* The action key. The icon strip on a card is unlabelled, so one permanent
|
||||||
line under the tabs names every glyph. It follows the tab rather than the
|
line under the tabs names every glyph. It follows the tab rather than the
|
||||||
row: the archived and finished buckets swap Archive for Restore, and a
|
row: the archived bucket swaps Archive for Restore. */}}
|
||||||
finished series has no Done to offer. */}}
|
|
||||||
{{define "keyrow"}}
|
{{define "keyrow"}}
|
||||||
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
|
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
|
||||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
|
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
|
||||||
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
|
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
|
||||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
|
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
|
||||||
{{if or (eq .Tab "archived") (eq .Tab "finished")}}
|
{{if eq .Tab "archived"}}
|
||||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
|
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
|
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if ne .Tab "finished"}}
|
|
||||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg><span>Done</span></span>
|
|
||||||
{{end}}
|
|
||||||
<span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
|
<span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
<symbol id="i-star-on" viewBox="0 0 24 24"><path d="M12 3.6l2.6 5.6 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8z" fill="currentColor" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
<symbol id="i-star-on" viewBox="0 0 24 24"><path d="M12 3.6l2.6 5.6 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8z" fill="currentColor" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||||
<symbol id="i-pencil" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
<symbol id="i-pencil" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||||
<symbol id="i-box" viewBox="0 0 24 24"><path d="M3 7h18v4H3zM5 11v9h14v-9M10 15h4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
<symbol id="i-box" viewBox="0 0 24 24"><path d="M3 7h18v4H3zM5 11v9h14v-9M10 15h4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M4 12.5l5.2 5.5L20 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
|
||||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V4h5v3M6.5 7l1 13h9l1-13M10.5 11v6M13.5 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V4h5v3M6.5 7l1 13h9l1-13M10.5 11v6M13.5 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||||
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M4 9h9.5a5 5 0 010 10H8M4 9l4.2-4.2M4 9l4.2 4.2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M4 9h9.5a5 5 0 010 10H8M4 9l4.2-4.2M4 9l4.2 4.2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M15.3 15.3L20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M15.3 15.3L20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
<div class="empty"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
|
<div class="empty"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
|
||||||
{{else if eq .Tab "archived"}}
|
{{else if eq .Tab "archived"}}
|
||||||
<div class="empty"><strong>Nothing archived.</strong><p>Shelve a series to park it here — it keeps getting checked for new chapters.</p></div>
|
<div class="empty"><strong>Nothing archived.</strong><p>Shelve a series to park it here — it keeps getting checked for new chapters.</p></div>
|
||||||
{{else if eq .Tab "finished"}}
|
|
||||||
<div class="empty"><strong>Nothing finished yet.</strong><p>Mark a series finished and it moves out of your reading list.</p></div>
|
|
||||||
{{else if .EmptyLibrary}}
|
{{else if .EmptyLibrary}}
|
||||||
{{/* Nothing in either library, so the links are the only thing this page can
|
{{/* Nothing in either library, so the links are the only thing this page can
|
||||||
usefully say. Both scripts: the two libraries are separate installs. */}}
|
usefully say. Both scripts: the two libraries are separate installs. */}}
|
||||||
|
|||||||
+10
-11
@@ -293,10 +293,11 @@ func libOf(q string) string {
|
|||||||
// buildListView loads one reader's list once and derives both the tab-filtered
|
// buildListView loads one reader's list once and derives both the tab-filtered
|
||||||
// items and the recent strip from it.
|
// items and the recent strip from it.
|
||||||
//
|
//
|
||||||
// Archived and finished series appear in their own tab and nowhere else — not
|
// Archived series appear in their own tab and nowhere else — not in All, not
|
||||||
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
// in Updated, not in Favourites, and not in the recent strip. An archived
|
||||||
// archived favourite therefore shows only under Archived: Favourites means
|
// favourite therefore shows only under Archived: Favourites means "favourites
|
||||||
// "favourites I am currently reading".
|
// I am currently reading". There is no Finished tab: finished is a fact about
|
||||||
|
// the Series, not a bookmark bucket (issue #157).
|
||||||
func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, error) {
|
func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, error) {
|
||||||
all, err := h.store.List(readerID) // already ordered updated_at DESC
|
all, err := h.store.List(readerID) // already ordered updated_at DESC
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -327,8 +328,6 @@ func (h *Handler) buildListView(readerID int64, lib, tab string) (listView, erro
|
|||||||
items = withNew
|
items = withNew
|
||||||
case "archived":
|
case "archived":
|
||||||
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived })
|
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived })
|
||||||
case "finished":
|
|
||||||
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusFinished })
|
|
||||||
default:
|
default:
|
||||||
tab = "all"
|
tab = "all"
|
||||||
items = reading
|
items = reading
|
||||||
@@ -392,7 +391,7 @@ func currentLib(r *http.Request) string {
|
|||||||
// writeChromeOOB appends the regions that live outside #list — the recent
|
// writeChromeOOB appends the regions that live outside #list — the recent
|
||||||
// strip, the Updated badge and the action key — as out-of-band swaps, so a
|
// strip, the Updated badge and the action key — as out-of-band swaps, so a
|
||||||
// mutation cannot leave them describing the library as it was before the tap.
|
// mutation cannot leave them describing the library as it was before the tap.
|
||||||
// The key is in here because it is tab-shaped too: archived and finished swap
|
// The key is in here because it is tab-shaped too: the archived bucket swaps
|
||||||
// Archive for Restore.
|
// Archive for Restore.
|
||||||
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||||
view.OOB = true
|
view.OOB = true
|
||||||
@@ -492,9 +491,9 @@ func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.saveAndRenderCard(w, r, b)
|
h.saveAndRenderCard(w, r, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
|
// uiStatus moves a bookmark between the two lifecycle buckets. Finished is not
|
||||||
// a series can be marked finished — the JSON API refuses that value, so the
|
// one of them: it is a fact about the Series, decided from the admin surface,
|
||||||
// userscript cannot set it even by accident.
|
// so the web UI's per-bookmark control cannot set it (issue #157).
|
||||||
//
|
//
|
||||||
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
||||||
// list does not reorder.
|
// list does not reorder.
|
||||||
@@ -508,7 +507,7 @@ func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch s := r.PostFormValue("status"); s {
|
switch s := r.PostFormValue("status"); s {
|
||||||
case store.StatusReading, store.StatusArchived, store.StatusFinished:
|
case store.StatusReading, store.StatusArchived:
|
||||||
b.Status = s
|
b.Status = s
|
||||||
default:
|
default:
|
||||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func TestPutStatusValidation(t *testing.T) {
|
|||||||
{"empty is no opinion", "", http.StatusOK},
|
{"empty is no opinion", "", http.StatusOK},
|
||||||
{"reading", "reading", http.StatusOK},
|
{"reading", "reading", http.StatusOK},
|
||||||
{"archived", "archived", http.StatusOK},
|
{"archived", "archived", http.StatusOK},
|
||||||
{"finished is web-only", "finished", http.StatusBadRequest},
|
{"finished is no longer a bucket", "finished", http.StatusBadRequest},
|
||||||
{"garbage", "dropped", http.StatusBadRequest},
|
{"garbage", "dropped", http.StatusBadRequest},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
+19
-21
@@ -1523,8 +1523,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
|||||||
body := rr.Body.String()
|
body := rr.Body.String()
|
||||||
|
|
||||||
want := `hx-target="[id='card-asura:solo']"`
|
want := `hx-target="[id='card-asura:solo']"`
|
||||||
if strings.Count(body, want) != 5 {
|
if strings.Count(body, want) != 4 {
|
||||||
t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)",
|
t.Fatalf("body has %d occurrences of %s, want 4 (favorite, archive, delete buttons, chapter form)",
|
||||||
strings.Count(body, want), want)
|
strings.Count(body, want), want)
|
||||||
}
|
}
|
||||||
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
|
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
|
||||||
@@ -1748,8 +1748,6 @@ func seedStatusRows(t *testing.T, st *store.Store) {
|
|||||||
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
||||||
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
|
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
|
||||||
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
||||||
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
|
||||||
Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
|
|
||||||
}
|
}
|
||||||
for _, b := range rows {
|
for _, b := range rows {
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
b.UpdatedAt = time.Now().UnixMilli()
|
||||||
@@ -1758,7 +1756,6 @@ func seedStatusRows(t *testing.T, st *store.Store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
srv, st := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
@@ -1768,11 +1765,10 @@ func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
|||||||
tab string
|
tab string
|
||||||
want, dontWant []string
|
want, dontWant []string
|
||||||
}{
|
}{
|
||||||
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
{"all", []string{"ReadingOne"}, []string{"ArchivedOne"}},
|
||||||
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
{"new", []string{"ReadingOne"}, []string{"ArchivedOne"}},
|
||||||
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne"}},
|
||||||
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}},
|
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne"}},
|
||||||
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.tab, func(t *testing.T) {
|
t.Run(tc.tab, func(t *testing.T) {
|
||||||
@@ -1835,12 +1831,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
|||||||
if !strings.Contains(strip, "ReadingOne") {
|
if !strings.Contains(strip, "ReadingOne") {
|
||||||
t.Fatal("strip dropped the series with an unread chapter")
|
t.Fatal("strip dropped the series with an unread chapter")
|
||||||
}
|
}
|
||||||
for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne", "FinishedOne"} {
|
for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne"} {
|
||||||
if strings.Contains(strip, unwanted) {
|
if strings.Contains(strip, unwanted) {
|
||||||
t.Fatalf("strip included %s", unwanted)
|
t.Fatalf("strip included %s", unwanted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, tab := range []string{"new", "fav", "archived", "finished"} {
|
for _, tab := range []string{"new", "fav", "archived"} {
|
||||||
if strings.Contains(stripOf(t, srv, st, tab), "ReadingOne") {
|
if strings.Contains(stripOf(t, srv, st, tab), "ReadingOne") {
|
||||||
t.Fatalf("tab %s rendered the strip", tab)
|
t.Fatalf("tab %s rendered the strip", tab)
|
||||||
}
|
}
|
||||||
@@ -1903,7 +1899,7 @@ func TestUIStatusSetsBucket(t *testing.T) {
|
|||||||
srv, st := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, st)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
|
for _, want := range []string{store.StatusArchived, store.StatusReading} {
|
||||||
if rr := postStatus(t, srv, st, "asura:reading", want); rr.Code != http.StatusOK {
|
if rr := postStatus(t, srv, st, "asura:reading", want); rr.Code != http.StatusOK {
|
||||||
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
@@ -1971,12 +1967,11 @@ func TestCardShowsStatusControls(t *testing.T) {
|
|||||||
tab string
|
tab string
|
||||||
want, dontWant []string
|
want, dontWant []string
|
||||||
}{
|
}{
|
||||||
// A series being read can be shelved or completed, not restored.
|
// A series being read can be shelved, not restored, and there is no
|
||||||
{"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
// finish control any more (issue #157).
|
||||||
// An archived one can come back or be completed.
|
{"all", []string{`hx-vals='{"status":"archived"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
||||||
{"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
// An archived one can come back.
|
||||||
// A finished one can only come back.
|
{"archived", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
||||||
{"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.tab, func(t *testing.T) {
|
t.Run(tc.tab, func(t *testing.T) {
|
||||||
@@ -2010,11 +2005,14 @@ func TestAppRendersNewTabs(t *testing.T) {
|
|||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rr, req)
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
for _, want := range []string{`href="/?tab=archived"`, `href="/?tab=finished"`} {
|
for _, want := range []string{`href="/?tab=archived"`} {
|
||||||
if !strings.Contains(rr.Body.String(), want) {
|
if !strings.Contains(rr.Body.String(), want) {
|
||||||
t.Fatalf("app page missing %s", want)
|
t.Fatalf("app page missing %s", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(rr.Body.String(), `href="/?tab=finished"`) {
|
||||||
|
t.Fatal("app page still offers a Finished tab")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A mutation has to bring the chrome with it: the strip and the badge live
|
// A mutation has to bring the chrome with it: the strip and the badge live
|
||||||
@@ -2131,7 +2129,6 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
|
|||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"/?lib=novel&tab=fav",
|
"/?lib=novel&tab=fav",
|
||||||
"/?lib=novel&tab=archived",
|
"/?lib=novel&tab=archived",
|
||||||
"/?lib=novel&tab=finished",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Fatalf("novel page missing tab link %s", want)
|
t.Fatalf("novel page missing tab link %s", want)
|
||||||
@@ -2142,6 +2139,7 @@ func TestNovelPageOmitsUpdatedTab(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
func TestMangaPageKeepsUpdatedTab(t *testing.T) {
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
srv, st := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# ADR-0015: Finished is a fact about the Series, not a bookmark bucket
|
||||||
|
|
||||||
|
Date: 2026-08-22
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
"Finished" moves from the per-Reader `bookmarks.status` bucket to a
|
||||||
|
Series-owned flag: `series.finished_at`, unix ms, zero while the Series is
|
||||||
|
still running. The Lane's gate reads the flag — a Series is polled only while
|
||||||
|
`finished_at = 0` — and `bookmarks.status` keeps exactly two values,
|
||||||
|
`reading` and `archived`.
|
||||||
|
|
||||||
|
The cutover is one-way, done by migration 0016 in three load-bearing
|
||||||
|
statements:
|
||||||
|
|
||||||
|
1. `ALTER TABLE series ADD COLUMN finished_at bigint NOT NULL DEFAULT 0`.
|
||||||
|
2. Seed it from the bookmarks: a Series is stamped finished when no bookmark
|
||||||
|
on it is outside the `finished` bucket. This mirrors the pre-cutover due
|
||||||
|
gate exactly — the old query skipped a Series only while
|
||||||
|
`COUNT(*) FILTER (WHERE status <> 'finished') = 0` — so no Series changes
|
||||||
|
polling state at the cutover.
|
||||||
|
3. Rewrite every `finished` bookmark to `archived`. The bucket is gone; the
|
||||||
|
seed ran first because it is the only statement that can still read it.
|
||||||
|
|
||||||
|
The JSON API rejects a `finished` status with the same plain 400 as any
|
||||||
|
unknown value, and the web UI no longer offers a Finished tab, a finish
|
||||||
|
button, or a finished state badge.
|
||||||
|
|
||||||
|
## Why a future reader will find this surprising
|
||||||
|
|
||||||
|
The bucket looked Reader-shaped but described a Series fact. A Series is
|
||||||
|
finished once, and every Reader reading it is then on a finished Series —
|
||||||
|
yet the bucket carried three copies of the answer, one per Reader, free to
|
||||||
|
disagree. The disagreement is not theoretical: a second Reader who merely
|
||||||
|
kept the Series (or never read it) kept it in `reading`, so the poll gate
|
||||||
|
kept fetching a Series the first Reader had closed out, forever. Worse, the
|
||||||
|
disagreement was never resolvable — nothing in the system could say "this
|
||||||
|
Series is finished" without rewriting every bookmark, which silently edits
|
||||||
|
another Reader's progress state.
|
||||||
|
|
||||||
|
The flag is also the only memory of the bucket after the flip. `finished`
|
||||||
|
bookmarks become `archived` because a two-value status needs no third
|
||||||
|
value, and an archived row must keep meaning "shelved, but the Series is
|
||||||
|
being watched" — which is what the row says. The migration's seed is what
|
||||||
|
keeps the legacy meaning: a Series every Reader finished is stamped, so the
|
||||||
|
Lane stops polling it just as it would have pre-cutover; a Series any
|
||||||
|
Reader still reads is left alone, exactly as the old gate left it. A
|
||||||
|
Series whose every Reader only shelved (archived) continues to be polled,
|
||||||
|
because an archived bookmark is *supposed* to be polled — the cutover
|
||||||
|
changes the answer, it does not invent it. And because the flag is a series
|
||||||
|
fact, the cutover also repairs the disagreement case: the moment one Reader
|
||||||
|
has the Series open, it reads as finished to everyone.
|
||||||
|
|
||||||
|
The migration is the only writer of the flag today; the undo is writing 0,
|
||||||
|
which returns the Series to the poll. An owner-facing "mark finished" write
|
||||||
|
is deliberately not part of this change — the gate is what this ticket
|
||||||
|
rewrites, and the write can land on top of it without touching anything
|
||||||
|
here.
|
||||||
|
|
||||||
|
The userscript merge ranks `archived > reading` now. `finished` is not a
|
||||||
|
value the wire can carry, so the merge cannot un-finish a row — it cannot
|
||||||
|
even name the state it is protecting.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
**Keep the bucket and add the flag alongside it, both live.**
|
||||||
|
Rejected: two sources of truth for one fact, with the Lane forced to
|
||||||
|
resolve "any Reader finished?" on every due query and every Reader write
|
||||||
|
still able to resurrect a finished Series. The whole point of the change is
|
||||||
|
that the finished state survives Readers.
|
||||||
|
|
||||||
|
**Stamp a Series finished when every bookmark is finished *or* archived.**
|
||||||
|
Rejected: it flips polling state at the cutover. Shelved-only Series were
|
||||||
|
polled before; making them finished stops the checks the reader knowingly
|
||||||
|
asked to keep.
|
||||||
|
|
||||||
|
**Finish as "no bookmark is reading", leaving the buckets untouched.**
|
||||||
|
Rejected for the same reason plus one: `archived` is a Reader's own state
|
||||||
|
and the flip is what makes the flag the *only* source of finished. Keeping
|
||||||
|
the `finished` value in the table would force every status validation,
|
||||||
|
merge and UI branch to keep handling a value no write can produce.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `bookmarks.status` is validated to `reading | archived`, empty meaning
|
||||||
|
"keep the stored value"; the API's 400 for `finished` is now the generic
|
||||||
|
invalid-status rejection rather than a special case, and the web UI's own
|
||||||
|
status control rejects it the same way.
|
||||||
|
- The Lane due query and the eligible count read `finished_at`; a Forced
|
||||||
|
Poll (issue #146) still overrides the flag — the owner asked, so the Lane
|
||||||
|
looks — and the pending force clears itself when the pass stamps the
|
||||||
|
check timestamp, never by touching `finished_at`.
|
||||||
|
- The web UI has no Finished tab; finished Series render in Archived with
|
||||||
|
their archived badge, dimmed like any shelved row.
|
||||||
|
- Migration 0016 stamps `finished_at` with the migration's own clock
|
||||||
|
(`now()` ms), which is also the undo: write 0 and the Series returns to
|
||||||
|
the poll.
|
||||||
|
|
||||||
|
## Cost of reversing
|
||||||
|
|
||||||
|
The finished buckets are destroyed by the flip; reversing means re-deriving
|
||||||
|
per-Reader finished state from a Series fact that now encodes the
|
||||||
|
majority-agreement snapshot plus whatever the owner reset since. The stamp
|
||||||
|
differentiates "finished at the cutover and untouched" from "finished
|
||||||
|
later", but not which Reader's choice each Series carried, and any Series
|
||||||
|
the owner has since restored is gone from the derivation entirely. The ADR
|
||||||
|
is a statement of intent to ship the one-way cutover and live with its
|
||||||
|
consequences; the per-Reader history is not kept anywhere in the schema.
|
||||||
@@ -471,9 +471,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only an explicit archive/restore has an opinion about the bucket. Every
|
// Only an explicit archive/restore has an opinion about the bucket. Every
|
||||||
// other write omits `status`, so the server keeps the stored one — otherwise
|
// other write omits `status`, so the server keeps the stored one —
|
||||||
// a cached value would resend "finished" (which the API rejects with 400) or
|
// otherwise a cached value would resend a stale status (the API rejects
|
||||||
// silently un-archive a series archived on another device.
|
// unknown values with 400) or silently un-archive a series archived on
|
||||||
|
// another device.
|
||||||
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
||||||
const body = Object.assign({}, obj);
|
const body = Object.assign({}, obj);
|
||||||
if (!sendStatus) delete body.status;
|
if (!sendStatus) delete body.status;
|
||||||
@@ -888,8 +889,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Archive parks a series: it leaves All and Favourites but the server keeps
|
// Archive parks a series: it leaves All and Favourites but the server keeps
|
||||||
// polling it for new chapters. "finished" is deliberately not reachable from
|
// polling it for new chapters. finished is not a bucket the script can
|
||||||
// here — the API rejects that value, it is a web-UI decision.
|
// reach — it is a fact about the Series now (issue #157).
|
||||||
async function toggleArchive(key) {
|
async function toggleArchive(key) {
|
||||||
const existing = state.byKey[key];
|
const existing = state.byKey[key];
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
@@ -966,7 +967,6 @@
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const due = state.list
|
const due = state.list
|
||||||
.filter((b) => b.site === site && b.series_url)
|
.filter((b) => b.site === site && b.series_url)
|
||||||
.filter((b) => statusOf(b) !== "finished")
|
|
||||||
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
||||||
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
||||||
.slice(0, LATEST_CHECK_BATCH);
|
.slice(0, LATEST_CHECK_BATCH);
|
||||||
@@ -1335,8 +1335,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
||||||
// Archived rows are hidden from All and Favourites, and finished ones —
|
// Archived rows are hidden from All and Favourites; a Series that is
|
||||||
// which only the web UI can set — are hidden from every tab.
|
// finished is the web UI's business and arrives archived (issue #157).
|
||||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||||
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,8 +268,9 @@
|
|||||||
// Duplicate case (spec user story 2): one row must survive, and it is
|
// Duplicate case (spec user story 2): one row must survive, and it is
|
||||||
// the one already under the repaired key — with the stale row's
|
// the one already under the repaired key — with the stale row's
|
||||||
// progress carried across when it is ahead, favourite OR'd, and the
|
// progress carried across when it is ahead, favourite OR'd, and the
|
||||||
// stronger lifecycle bucket kept (finished > archived > reading, so a
|
// stronger lifecycle bucket kept (archived > reading, so a merge can
|
||||||
// merge can never silently un-archive or un-finish a row).
|
// never silently un-archive a row). finished is not a bucket the
|
||||||
|
// script can see (issue #157).
|
||||||
const merged = Object.assign({}, existing);
|
const merged = Object.assign({}, existing);
|
||||||
if (
|
if (
|
||||||
existing.last_chapter_num == null ||
|
existing.last_chapter_num == null ||
|
||||||
@@ -280,7 +281,7 @@
|
|||||||
merged.last_chapter_url = stale.last_chapter_url;
|
merged.last_chapter_url = stale.last_chapter_url;
|
||||||
}
|
}
|
||||||
merged.favorite = !!(existing.favorite || stale.favorite);
|
merged.favorite = !!(existing.favorite || stale.favorite);
|
||||||
const rank = (s) => ({ finished: 2, archived: 1, reading: 0 }[s || "reading"] || 0);
|
const rank = (s) => ({ archived: 1, reading: 0 }[s || "reading"] || 0);
|
||||||
merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status;
|
merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status;
|
||||||
merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0);
|
merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0);
|
||||||
listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b));
|
listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b));
|
||||||
@@ -405,9 +406,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only an explicit archive/restore has an opinion about the bucket. Every
|
// Only an explicit archive/restore has an opinion about the bucket. Every
|
||||||
// other write omits `status`, so the server keeps the stored one — otherwise
|
// other write omits `status`, so the server keeps the stored one —
|
||||||
// a cached value would resend "finished" (which the API rejects with 400) or
|
// otherwise a cached value would resend a stale status (the API rejects
|
||||||
// silently un-archive a series archived on another device.
|
// unknown values with 400) or silently un-archive a series archived on
|
||||||
|
// another device.
|
||||||
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
||||||
const body = Object.assign({}, obj);
|
const body = Object.assign({}, obj);
|
||||||
if (!sendStatus) delete body.status;
|
if (!sendStatus) delete body.status;
|
||||||
@@ -792,8 +794,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Archive parks a series: it leaves All and Favourites but the server keeps
|
// Archive parks a series: it leaves All and Favourites but the server keeps
|
||||||
// polling it for new chapters. "finished" is deliberately not reachable from
|
// polling it for new chapters. finished is not a bucket the script can
|
||||||
// here — the API rejects that value, it is a web-UI decision.
|
// reach — it is a fact about the Series now (issue #157).
|
||||||
async function toggleArchive(key) {
|
async function toggleArchive(key) {
|
||||||
const existing = state.byKey[key];
|
const existing = state.byKey[key];
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
@@ -878,7 +880,6 @@
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const due = state.list
|
const due = state.list
|
||||||
.filter((b) => b.site === site && b.series_url)
|
.filter((b) => b.site === site && b.series_url)
|
||||||
.filter((b) => statusOf(b) !== "finished")
|
|
||||||
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
||||||
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
||||||
.slice(0, LATEST_CHECK_BATCH);
|
.slice(0, LATEST_CHECK_BATCH);
|
||||||
@@ -1241,8 +1242,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
||||||
// Archived rows are hidden from All and Favourites, and finished ones —
|
// Archived rows are hidden from All and Favourites; a Series that is
|
||||||
// which only the web UI can set — are hidden from every tab.
|
// finished is the web UI's business and arrives archived (issue #157).
|
||||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||||
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,7 +395,6 @@ test("statusOf defaults a missing status to reading", () => {
|
|||||||
assert.equal(statusOf({}), "reading");
|
assert.equal(statusOf({}), "reading");
|
||||||
assert.equal(statusOf({ status: "" }), "reading");
|
assert.equal(statusOf({ status: "" }), "reading");
|
||||||
assert.equal(statusOf({ status: "archived" }), "archived");
|
assert.equal(statusOf({ status: "archived" }), "archived");
|
||||||
assert.equal(statusOf({ status: "finished" }), "finished");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user