Archived and finished buckets, userscript nav chips #4

Merged
sulthan merged 10 commits from feat/status-buckets into main 2026-07-27 16:58:06 +07:00
4 changed files with 83 additions and 6 deletions
Showing only changes of commit 7a16abd6a3 - Show all commits
+36
View File
@@ -160,3 +160,39 @@ func TestPutStatusValidation(t *testing.T) {
}) })
} }
} }
// A PUT that omits the status field entirely (what a userscript build
// predating the column sends) is the actual preserve path — the "" case
// above only exercises the fresh-INSERT default and never touches an
// existing bucket. This must both keep the archived bucket and still apply
// the chapter progress carried in the same request.
func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
srv := newTestServer(t)
seed := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
strings.NewReader(`{"title":"Solo","status":"archived"}`))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(seed))
if rr.Code != http.StatusOK {
t.Fatalf("seed status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
strings.NewReader(`{"title":"Solo","last_chapter":"12","last_chapter_num":12}`))
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(req))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
var got Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Status != "archived" {
t.Fatalf("stored status = %q, want %q", got.Status, "archived")
}
if got.LastChapterNum != 12 {
t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum)
}
}
+4 -3
View File
@@ -193,10 +193,11 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
if latestChapterNum.Valid { if latestChapterNum.Valid {
b.LatestChapterNum = &latestChapterNum.Float64 b.LatestChapterNum = &latestChapterNum.Float64
} }
// A NULL or empty bucket would leave the row in no list at all, so it // A NULL, empty, or unrecognised bucket (e.g. a hand-edited row) would
// reads as the default rather than being passed through. // leave the row in no list at all, so anything outside the three known
// buckets reads as the default rather than being passed through.
b.Status = status.String b.Status = status.String
if b.Status == "" { if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished {
b.Status = statusReading b.Status = statusReading
} }
return b, nil return b, nil
+33
View File
@@ -760,6 +760,39 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
} }
} }
// Mirrors the poller's read-modify-write in latest.go: Get the current row,
// mutate only the latest-chapter fields, and Upsert the whole struct back.
// cur.Status comes from Get (never empty — see scanBookmark), so it must
// round-trip through Upsert unchanged rather than being reset.
func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
store := newTestStore(t)
base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
}
if _, err := store.Upsert(base); err != nil {
t.Fatalf("seed: %v", err)
}
cur, found, err := store.Get(base.Key)
if err != nil || !found {
t.Fatalf("Get: found=%v err=%v", found, err)
}
num := 7.0
cur.LatestChapter = "7"
cur.LatestChapterNum = &num
cur.UpdatedAt = time.Now().UnixMilli()
stored, err := store.Upsert(cur)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if stored.Status != statusArchived {
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
}
}
func TestUpsertReplacesStatusWhenGiven(t *testing.T) { func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
base := Bookmark{ base := Bookmark{
+10 -3
View File
@@ -271,11 +271,17 @@
return res.json(); return res.json();
} }
async function apiPut(key, obj) { // Only an explicit archive/restore has an opinion about the bucket. Every
// other write omits `status`, so the server keeps the stored one — otherwise
// a cached value would resend "finished" (which the API rejects with 400) or
// silently un-archive a series archived on another device.
async function apiPut(key, obj, { sendStatus = false } = {}) {
const body = Object.assign({}, obj);
if (!sendStatus) delete body.status;
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
method: "PUT", method: "PUT",
headers: authHeaders({ "Content-Type": "application/json" }), headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(obj), body: JSON.stringify(body),
}); });
if (!res.ok) throw new Error("PUT /bookmarks " + res.status); if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
return res.json(); return res.json();
@@ -433,7 +439,7 @@
upsertLocal(bm); upsertLocal(bm);
render(); render();
try { try {
const saved = await apiPut(bm.key, bm); const saved = await apiPut(bm.key, bm, { sendStatus: true });
upsertLocal(saved); // adopt the stored row: the server owns updated_at upsertLocal(saved); // adopt the stored row: the server owns updated_at
render(); render();
toast(next === "archived" ? "Archived" : "Back in your list"); toast(next === "archived" ? "Archived" : "Back in your list");
@@ -489,6 +495,7 @@
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);