Archived and finished buckets, userscript nav chips #4
@@ -3,6 +3,7 @@
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
backend/server
|
||||
backend/backend
|
||||
.playwright-mcp/
|
||||
graphify-out/
|
||||
plans/
|
||||
|
||||
@@ -57,6 +57,16 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
||||
value now differs, moving `updated_at` and reordering the list. This is a
|
||||
known, accepted limitation for a single-user deployment, not a bug to fix.
|
||||
- **`updated_at` drives list order, so it moves only on real reading progress:** the server applies its timestamp when the row is new or `last_chapter_num` changes, and otherwise keeps the stored value — favouriting a series or recording a newly published chapter must not reorder the list. `PUT` therefore returns the row **as stored**, and clients must adopt that response rather than their own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
|
||||
- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` |
|
||||
`finished`, orthogonal to `favorite`. Archived and finished appear only in
|
||||
their own tab — not in All, Updated, Favourites, or the recent strip. The
|
||||
poller keeps checking archived series and skips finished ones. `finished` is
|
||||
settable only from the web UI; `PUT /bookmarks/{key}` rejects it with 400.
|
||||
**An empty incoming status means "keep the stored one"** — resolved on the
|
||||
`VALUES` side of `Store.Upsert`, not in the conflict clause, because
|
||||
`excluded.*` is the post-evaluation row and a default applied there would
|
||||
wipe the bucket on every PUT from a client that predates the column. See
|
||||
`docs/superpowers/specs/2026-07-27-status-buckets-design.md`.
|
||||
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
|
||||
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
|
||||
(gates the browser UI; unset disables it),
|
||||
@@ -68,7 +78,10 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
||||
1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
|
||||
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback.
|
||||
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
||||
4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS (critical on mobile).
|
||||
4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS
|
||||
(critical on mobile). Three tabs (All / Favourites / Archived) and a row of
|
||||
link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG
|
||||
block next to `API_BASE`.
|
||||
5. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices).
|
||||
|
||||
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
||||
|
||||
@@ -148,6 +148,15 @@ desktop for faster testing — install the same file unchanged.
|
||||
- **Favourites**: the ☆ on any row toggles it; the **★ Favourites** tab narrows
|
||||
the list. Favourited series still appear under **All**. The flag syncs, so it
|
||||
follows you across devices; the chosen tab does not persist.
|
||||
- **Archive**: the **Archive** button on any row parks a series — it drops out
|
||||
of **All** and **★ Favourites** and moves to the **Archived** tab. The server
|
||||
keeps checking it for new chapters, so it is worth coming back to. Archiving
|
||||
does not touch read progress, and reading an archived series leaves it
|
||||
archived.
|
||||
- **Finished**: series you have completed live in a **Finished** tab in the web
|
||||
UI only. It is set there and nowhere else — the API rejects the value — and
|
||||
finished series are hidden from every userscript tab and are no longer polled
|
||||
for new chapters.
|
||||
- Bookmarks made on Asura appear when the panel is opened on Demonic, and vice
|
||||
versa — the backend is the shared store.
|
||||
|
||||
|
||||
@@ -60,6 +60,21 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// rather than trusting every client to leave it alone.
|
||||
switch b.Status {
|
||||
case "", statusReading, statusArchived:
|
||||
case statusFinished:
|
||||
http.Error(w, "status "+statusFinished+" can only be set from the web UI",
|
||||
http.StatusBadRequest)
|
||||
return
|
||||
default:
|
||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Candidate timestamp, not a decision: Upsert keeps the stored one unless
|
||||
// reading progress actually moved. Any client value is ignored.
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
|
||||
@@ -148,6 +148,13 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
||||
|
||||
// Re-read: the row may have been updated or deleted while the fetch was in
|
||||
// flight, and writing b back wholesale would undo that.
|
||||
//
|
||||
// ponytail: non-transactional read-modify-write, wrap Get+Upsert in a tx if
|
||||
// this ever runs for more than one user. A client PUT that commits between
|
||||
// these two statements is lost to the stale re-read — reverting read
|
||||
// progress or a status change, and moving updated_at because the stored
|
||||
// value now differs. Accepted for a single-user deployment: the window is
|
||||
// milliseconds and the loser is one poll cycle.
|
||||
cur, found, err := p.store.Get(b.Key)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %q: reread: %v", b.Key, err)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -113,3 +118,81 @@ func TestLoadLatestPollClampsAndFallsBack(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutStatusValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
want int
|
||||
}{
|
||||
{"empty is no opinion", "", http.StatusOK},
|
||||
{"reading", "reading", http.StatusOK},
|
||||
{"archived", "archived", http.StatusOK},
|
||||
{"finished is web-only", "finished", http.StatusBadRequest},
|
||||
{"garbage", "dropped", http.StatusBadRequest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
body := fmt.Sprintf(`{"title":"Solo","status":%q}`, tc.status)
|
||||
req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo",
|
||||
strings.NewReader(body)))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != tc.want {
|
||||
t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String())
|
||||
}
|
||||
if tc.want != http.StatusOK {
|
||||
return
|
||||
}
|
||||
var got Bookmark
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
want := tc.status
|
||||
if want == "" {
|
||||
want = "reading"
|
||||
}
|
||||
if got.Status != want {
|
||||
t.Fatalf("stored status = %q, want %q", got.Status, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,10 +131,19 @@ button, .primary, [role="tab"] {
|
||||
|
||||
.search:focus-visible { outline: 2px solid var(--accent); }
|
||||
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
/* Five buttons no longer divide a phone's width legibly, so the row scrolls
|
||||
sideways instead of squeezing. The scrollbar is hidden: on mobile there is
|
||||
none anyway, and on desktop it would sit under the tab labels. */
|
||||
.tabs {
|
||||
display: flex; gap: 8px; margin-bottom: 16px;
|
||||
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.tabs [role="tab"] {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
padding-inline: 14px;
|
||||
white-space: nowrap;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface);
|
||||
|
||||
+44
-4
@@ -27,6 +27,10 @@ type Bookmark struct {
|
||||
LatestChapter string `json:"latest_chapter"`
|
||||
LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured
|
||||
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
|
||||
// Status is the lifecycle bucket: reading, archived, or finished.
|
||||
// Archived series stay polled for new chapters; finished ones do not.
|
||||
// Empty on the way in means "no opinion" — see Upsert.
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// HasNewChapter reports whether the site has published past the read point.
|
||||
@@ -45,6 +49,13 @@ func (b Bookmark) ContinueURL() string {
|
||||
return b.SeriesURL
|
||||
}
|
||||
|
||||
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
||||
const (
|
||||
statusReading = "reading"
|
||||
statusArchived = "archived"
|
||||
statusFinished = "finished"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -60,6 +71,7 @@ CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
latest_chapter TEXT NOT NULL DEFAULT '',
|
||||
latest_chapter_num REAL,
|
||||
latest_checked_at INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'reading',
|
||||
updated_at INTEGER NOT NULL
|
||||
);`
|
||||
|
||||
@@ -73,11 +85,14 @@ var addedColumns = []struct{ name, ddl string }{
|
||||
// sorts first so a new bookmark is picked up on the next tick with no
|
||||
// special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked.
|
||||
{"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`},
|
||||
// Lifecycle bucket. The DEFAULT backfills every pre-existing row as
|
||||
// 'reading', so there is no separate migration step.
|
||||
{"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`},
|
||||
}
|
||||
|
||||
const bookmarkColumns = `key, site, series_id, title, series_url, cover,
|
||||
last_chapter, last_chapter_num, last_chapter_url,
|
||||
favorite, latest_chapter, latest_chapter_num, updated_at`
|
||||
favorite, latest_chapter, latest_chapter_num, updated_at, status`
|
||||
|
||||
// Store is the SQLite-backed bookmark store.
|
||||
type Store struct {
|
||||
@@ -156,13 +171,14 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
b Bookmark
|
||||
title, seriesURL, cover sql.NullString
|
||||
lastChapter, lastChapterURL, latestChapter sql.NullString
|
||||
status sql.NullString
|
||||
lastChapterNum, latestChapterNum sql.NullFloat64
|
||||
favorite sql.NullInt64
|
||||
)
|
||||
if err := scan(
|
||||
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
|
||||
&lastChapter, &lastChapterNum, &lastChapterURL,
|
||||
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt,
|
||||
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status,
|
||||
); err != nil {
|
||||
return Bookmark{}, err
|
||||
}
|
||||
@@ -177,6 +193,13 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
||||
if latestChapterNum.Valid {
|
||||
b.LatestChapterNum = &latestChapterNum.Float64
|
||||
}
|
||||
// A NULL, empty, or unrecognised bucket (e.g. a hand-edited row) would
|
||||
// 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
|
||||
if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished {
|
||||
b.Status = statusReading
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
@@ -242,9 +265,19 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
||||
// IS NOT is SQLite's null-safe comparison. Within DO UPDATE, a bare column
|
||||
// is the stored row and excluded.* is the incoming one; a brand-new key
|
||||
// never reaches this clause, so it keeps the fresh timestamp from VALUES.
|
||||
//
|
||||
// The status column resolves on the VALUES side, not in the conflict
|
||||
// clause: excluded.* is the row *after* these expressions are evaluated,
|
||||
// so a default applied there would look identical to a real 'reading' and
|
||||
// would overwrite an archived row on every PUT from a client that knows
|
||||
// nothing about the column. Resolved once here, an empty incoming status
|
||||
// means "keep what is stored", and only a brand-new row falls through to
|
||||
// the literal default. The subquery runs inside this transaction, so it
|
||||
// sees the row this statement is about to conflict with.
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO bookmarks (`+bookmarkColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
site=excluded.site, series_id=excluded.series_id, title=excluded.title,
|
||||
series_url=excluded.series_url, cover=excluded.cover,
|
||||
@@ -253,6 +286,7 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
||||
favorite=excluded.favorite,
|
||||
latest_chapter=excluded.latest_chapter,
|
||||
latest_chapter_num=excluded.latest_chapter_num,
|
||||
status=excluded.status,
|
||||
updated_at=CASE
|
||||
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
|
||||
THEN excluded.updated_at
|
||||
@@ -260,7 +294,8 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
|
||||
END`,
|
||||
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
|
||||
b.LastChapter, b.LastChapterNum, b.LastChapterURL,
|
||||
b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt); err != nil {
|
||||
b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt,
|
||||
b.Status, b.Key); err != nil {
|
||||
return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err)
|
||||
}
|
||||
|
||||
@@ -293,10 +328,15 @@ func (s *Store) Delete(key string) error {
|
||||
//
|
||||
// Bookmarks with no series_url are skipped — there is nothing to fetch, which
|
||||
// is the same filter the userscript applies at L452.
|
||||
//
|
||||
// Finished series are excluded: nothing more is coming, so fetching them only
|
||||
// burns requests. Archived ones are deliberately still polled — knowing what a
|
||||
// shelved series is up to is the whole reason for archiving instead of deleting.
|
||||
func (s *Store) DueForLatestCheck(cutoffMs int64, limit int) ([]Bookmark, error) {
|
||||
rows, err := s.db.Query(`SELECT `+bookmarkColumns+`
|
||||
FROM bookmarks
|
||||
WHERE series_url IS NOT NULL AND series_url <> ''
|
||||
AND status IS NOT 'finished'
|
||||
AND latest_checked_at <= ?
|
||||
ORDER BY latest_checked_at ASC
|
||||
LIMIT ?`, cutoffMs, limit)
|
||||
|
||||
@@ -722,3 +722,186 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
stored, err := store.Upsert(Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != statusReading {
|
||||
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty status means "no opinion" — a userscript build that predates the
|
||||
// column must not reset the bucket on every progress PUT.
|
||||
func TestUpsertEmptyStatusPreservesStored(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)
|
||||
}
|
||||
|
||||
base.Status = ""
|
||||
base.LastChapterNum = 12
|
||||
stored, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != statusArchived {
|
||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
|
||||
base.Status = statusReading
|
||||
stored, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.Status != statusReading {
|
||||
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
||||
}
|
||||
}
|
||||
|
||||
// A status change is not reading progress, so the list must not reorder.
|
||||
func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
LastChapter: "45", LastChapterNum: 45,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
first, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
base.Status = statusArchived
|
||||
base.UpdatedAt = first.UpdatedAt + 60_000
|
||||
stored, err := store.Upsert(base)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
if stored.UpdatedAt != first.UpdatedAt {
|
||||
t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// A database written before the column existed must gain it, with every
|
||||
// pre-existing row landing in the reading bucket.
|
||||
func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "legacy.db")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE bookmarks (
|
||||
key TEXT PRIMARY KEY, site TEXT NOT NULL, series_id TEXT NOT NULL,
|
||||
title TEXT, series_url TEXT, cover TEXT,
|
||||
last_chapter TEXT, last_chapter_num REAL, last_chapter_url TEXT,
|
||||
updated_at INTEGER NOT NULL);
|
||||
INSERT INTO bookmarks (key, site, series_id, updated_at)
|
||||
VALUES ('asura:old', 'asura', 'old', 1)`); err != nil {
|
||||
t.Fatalf("seed legacy: %v", err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
store, err := OpenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
b, ok, err := store.Get("asura:old")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if b.Status != statusReading {
|
||||
t.Fatalf("Status = %q, want %q", b.Status, statusReading)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
for _, tc := range []struct{ key, status string }{
|
||||
{"asura:reading", statusReading},
|
||||
{"asura:archived", statusArchived},
|
||||
{"asura:finished", statusFinished},
|
||||
} {
|
||||
if _, err := store.Upsert(Bookmark{
|
||||
Key: tc.key, Site: "asura", SeriesID: tc.key,
|
||||
SeriesURL: "https://asurascans.com/comics/" + tc.key,
|
||||
Status: tc.status, UpdatedAt: time.Now().UnixMilli(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %s: %v", tc.key, err)
|
||||
}
|
||||
}
|
||||
|
||||
due, err := store.DueForLatestCheck(time.Now().UnixMilli(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, b := range due {
|
||||
got[b.Key] = true
|
||||
}
|
||||
if !got["asura:reading"] || !got["asura:archived"] {
|
||||
t.Fatalf("due = %v, want reading and archived present", got)
|
||||
}
|
||||
if got["asura:finished"] {
|
||||
t.Fatal("due included a finished series")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
|
||||
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||
<a role="tab" href="/?tab=archived" class="{{if eq .Tab "archived"}}active{{end}}"
|
||||
hx-get="/ui/list?tab=archived" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=archived" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||
<a role="tab" href="/?tab=finished" class="{{if eq .Tab "finished"}}active{{end}}"
|
||||
hx-get="/ui/list?tab=finished" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="/?tab=finished" hx-on::after-request="setActiveTab(this)">Finished</a>
|
||||
</nav>
|
||||
|
||||
{{if .Recent}}
|
||||
|
||||
@@ -38,6 +38,48 @@
|
||||
<path d="M13.8 7.2l3 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
{{if eq .Status "finished"}}
|
||||
<button class="icon on" title="Restore to reading" aria-label="Restore to reading"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
|
||||
</svg>
|
||||
</button>
|
||||
{{else}}
|
||||
{{if eq .Status "archived"}}
|
||||
<button class="icon on" title="Restore to reading" aria-label="Restore to reading"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M4 12a8 8 0 1 1 2.6 5.9M4 12V7M4 12h5"/>
|
||||
</svg>
|
||||
</button>
|
||||
{{else}}
|
||||
<button class="icon" title="Archive" aria-label="Archive"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"archived"}'
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M3.5 6.4h17v3.2h-17zM5 9.6h14V19H5zM10 13h4"/>
|
||||
</svg>
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="icon" title="Mark finished" aria-label="Mark finished"
|
||||
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">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M4.5 12.5l5 5 10-11"/>
|
||||
</svg>
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="icon danger" title="Remove" aria-label="Remove"
|
||||
onclick="toggleConfirmRow('{{.Key}}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
<p class="empty">No favourites yet. Star a series to pin it here.</p>
|
||||
{{else if eq .Tab "new"}}
|
||||
<p class="empty">Nothing new. Every series is caught up to its latest chapter.</p>
|
||||
{{else if eq .Tab "archived"}}
|
||||
<p class="empty">Nothing archived. Shelve a series to park it here — it keeps getting checked for new chapters.</p>
|
||||
{{else if eq .Tab "finished"}}
|
||||
<p class="empty">Nothing finished yet.</p>
|
||||
{{else}}
|
||||
<p class="empty">
|
||||
Nothing here yet. Bookmarks appear once the userscript records a chapter.
|
||||
|
||||
+63
-16
@@ -79,6 +79,7 @@ func (h *webHandler) register(mux *http.ServeMux) {
|
||||
|
||||
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList))
|
||||
mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite))
|
||||
mux.HandleFunc("POST /ui/bookmarks/{key}/status", h.requireSession(h.uiStatus))
|
||||
mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
|
||||
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
|
||||
}
|
||||
@@ -145,38 +146,51 @@ func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, http.StatusOK, "app", view)
|
||||
}
|
||||
|
||||
// filterBookmarks returns the subset keep reports true for, preserving order.
|
||||
// It always returns a non-nil slice so an empty tab renders its empty state.
|
||||
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
|
||||
out := []Bookmark{}
|
||||
for _, b := range all {
|
||||
if keep(b) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildListView loads the list once and derives both the tab-filtered items and
|
||||
// the recent strip from it. The strip always reflects overall recency, not the
|
||||
// active tab, so it is built before filtering.
|
||||
// the recent strip from it.
|
||||
//
|
||||
// Archived and finished series appear in their own tab and nowhere else — not
|
||||
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
||||
// archived favourite therefore shows only under Archived: Favourites means
|
||||
// "favourites I am currently reading".
|
||||
func (h *webHandler) buildListView(tab string) (listView, error) {
|
||||
all, err := h.store.List() // already ordered updated_at DESC
|
||||
if err != nil {
|
||||
return listView{}, err
|
||||
}
|
||||
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
|
||||
|
||||
recent := all
|
||||
// The strip reflects overall reading recency, not the active tab.
|
||||
recent := reading
|
||||
if len(recent) > recentCount {
|
||||
recent = recent[:recentCount]
|
||||
}
|
||||
|
||||
items := all
|
||||
var items []Bookmark
|
||||
switch tab {
|
||||
case "fav":
|
||||
items = []Bookmark{}
|
||||
for _, b := range all {
|
||||
if b.Favorite {
|
||||
items = append(items, b)
|
||||
}
|
||||
}
|
||||
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
|
||||
case "new":
|
||||
items = []Bookmark{}
|
||||
for _, b := range all {
|
||||
if b.HasNewChapter() {
|
||||
items = append(items, b)
|
||||
}
|
||||
}
|
||||
items = filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
|
||||
case "archived":
|
||||
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
|
||||
case "finished":
|
||||
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
|
||||
default:
|
||||
tab = "all"
|
||||
items = reading
|
||||
}
|
||||
return listView{Tab: tab, Recent: recent, Items: items}, nil
|
||||
}
|
||||
@@ -248,6 +262,13 @@ func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bo
|
||||
// saveAndRenderCard upserts and renders the row as stored. Upsert decides
|
||||
// whether updated_at moves, so the argument's timestamp is only a candidate and
|
||||
// the response must come from the return value.
|
||||
//
|
||||
// ponytail: the swapped card stays put even when its new status no longer
|
||||
// matches the active tab, add an hx-swap-oob list refresh if that reads as a
|
||||
// bug rather than as feedback. Archiving from the All tab leaves the card on
|
||||
// screen until the next list load. The alternative costs a full list round
|
||||
// trip on every toggle, and the card visibly showing its new state is the
|
||||
// feedback the user needs.
|
||||
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
|
||||
stored, err := h.store.Upsert(b)
|
||||
if err != nil {
|
||||
@@ -270,6 +291,32 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
h.saveAndRenderCard(w, b)
|
||||
}
|
||||
|
||||
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
|
||||
// a series can be marked finished — the JSON API refuses that value, so the
|
||||
// userscript cannot set it even by accident.
|
||||
//
|
||||
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
||||
// list does not reorder.
|
||||
func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := h.loadForMutation(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch s := r.PostFormValue("status"); s {
|
||||
case statusReading, statusArchived, statusFinished:
|
||||
b.Status = s
|
||||
default:
|
||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
h.saveAndRenderCard(w, b)
|
||||
}
|
||||
|
||||
// uiChapter forces the read chapter to a value the user typed.
|
||||
//
|
||||
// Writing the number also clears last_chapter_url: that URL points at the
|
||||
|
||||
+219
-2
@@ -308,8 +308,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||||
body := rr.Body.String()
|
||||
|
||||
want := `hx-target="[id='card-asura:solo']"`
|
||||
if strings.Count(body, want) != 3 {
|
||||
t.Fatalf("body has %d occurrences of %s, want 3 (favorite button, delete button, chapter form)",
|
||||
if strings.Count(body, want) != 5 {
|
||||
t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, delete buttons, chapter form)",
|
||||
strings.Count(body, want), want)
|
||||
}
|
||||
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
|
||||
@@ -513,3 +513,220 @@ func TestUIListNewTab(t *testing.T) {
|
||||
t.Fatal("new tab included a series already caught up")
|
||||
}
|
||||
}
|
||||
|
||||
// seedStatusRows puts one series in each bucket, the archived one also
|
||||
// favourited and with a new chapter out, so a leak into any reading-bucket tab
|
||||
// shows up as a failure rather than passing by accident.
|
||||
func seedStatusRows(t *testing.T, store *Store) {
|
||||
t.Helper()
|
||||
// floatPtr already exists in store_test.go — same package, reuse it.
|
||||
rows := []Bookmark{
|
||||
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
|
||||
Status: statusReading, LastChapterNum: 10, Favorite: true,
|
||||
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
|
||||
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
||||
Status: statusArchived, LastChapterNum: 5, Favorite: true,
|
||||
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
||||
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
||||
Status: statusFinished, LastChapterNum: 200, Favorite: true},
|
||||
}
|
||||
for _, b := range rows {
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
if _, err := store.Upsert(b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
cases := []struct {
|
||||
tab string
|
||||
want, dontWant []string
|
||||
}{
|
||||
{"all", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||||
{"new", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||||
{"fav", []string{"ReadingOne"}, []string{"ArchivedOne", "FinishedOne"}},
|
||||
{"archived", []string{"ArchivedOne"}, []string{"ReadingOne", "FinishedOne"}},
|
||||
{"finished", []string{"FinishedOne"}, []string{"ReadingOne", "ArchivedOne"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.tab, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
|
||||
req.AddCookie(sessionCookie(t, cfg))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, w := range tc.want {
|
||||
if !strings.Contains(body, w) {
|
||||
t.Fatalf("tab %s missing %s", tc.tab, w)
|
||||
}
|
||||
}
|
||||
for _, d := range tc.dontWant {
|
||||
if strings.Contains(body, d) {
|
||||
t.Fatalf("tab %s leaked %s", tc.tab, d)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentStripExcludesArchivedAndFinished(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/?tab=archived", nil)
|
||||
req.AddCookie(sessionCookie(t, cfg))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
|
||||
strip := rr.Body.String()
|
||||
if i := strings.Index(strip, `id="list"`); i >= 0 {
|
||||
strip = strip[:i] // everything above the list is the recent section
|
||||
}
|
||||
if strings.Contains(strip, "ArchivedOne") || strings.Contains(strip, "FinishedOne") {
|
||||
t.Fatal("recent strip included an archived or finished series")
|
||||
}
|
||||
if !strings.Contains(strip, "ReadingOne") {
|
||||
t.Fatal("recent strip dropped the reading series")
|
||||
}
|
||||
}
|
||||
|
||||
func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
form := url.Values{"status": {status}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/"+key+"/status",
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(sessionCookie(t, cfg))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestUIStatusSetsBucket(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
for _, want := range []string{statusArchived, statusFinished, statusReading} {
|
||||
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
|
||||
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
||||
}
|
||||
b, ok, err := store.Get("asura:reading")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if b.Status != want {
|
||||
t.Fatalf("stored status = %q, want %q", b.Status, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
b, _, _ := store.Get("asura:reading")
|
||||
if b.Status != statusReading {
|
||||
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIStatusRequiresSession(t *testing.T) {
|
||||
srv, store := newWebTestServer(t, webConfig())
|
||||
seedStatusRows(t, store)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
||||
strings.NewReader("status=archived"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
before, _, _ := store.Get("asura:reading")
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
after, _, _ := store.Get("asura:reading")
|
||||
if after.UpdatedAt != before.UpdatedAt {
|
||||
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardShowsStatusControls(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
cases := []struct {
|
||||
tab string
|
||||
want, dontWant []string
|
||||
}{
|
||||
// A series being read can be shelved or completed, not restored.
|
||||
{"all", []string{`hx-vals='{"status":"archived"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
||||
// An archived one can come back or be completed.
|
||||
{"archived", []string{`hx-vals='{"status":"reading"}'`, `hx-vals='{"status":"finished"}'`}, nil},
|
||||
// A finished one can only come back.
|
||||
{"finished", []string{`hx-vals='{"status":"reading"}'`}, []string{`hx-vals='{"status":"finished"}'`}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.tab, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/list?tab="+tc.tab, nil)
|
||||
req.AddCookie(sessionCookie(t, cfg))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
for _, w := range tc.want {
|
||||
if !strings.Contains(body, w) {
|
||||
t.Fatalf("tab %s missing control %s", tc.tab, w)
|
||||
}
|
||||
}
|
||||
for _, d := range tc.dontWant {
|
||||
if strings.Contains(body, d) {
|
||||
t.Fatalf("tab %s offered %s", tc.tab, d)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppRendersNewTabs(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(sessionCookie(t, cfg))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
|
||||
for _, want := range []string{`href="/?tab=archived"`, `href="/?tab=finished"`} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Fatalf("app page missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name Manga Bookmark Sync
|
||||
// @namespace mangabm
|
||||
// @version 1.2.0
|
||||
// @version 1.3.0
|
||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||
// @author you
|
||||
// @match https://asuracomic.net/*
|
||||
@@ -19,6 +19,7 @@
|
||||
// ============================================================
|
||||
const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
||||
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
|
||||
const WEB_BASE = "https://manga.violetcrown.my.id"; // the browser UI, for the panel's nav chips
|
||||
|
||||
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
||||
const CACHE_KEY = "mangabm:cache";
|
||||
@@ -270,11 +271,17 @@
|
||||
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), {
|
||||
method: "PUT",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(obj),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
||||
return res.json();
|
||||
@@ -325,6 +332,12 @@
|
||||
saveCache(state.list);
|
||||
}
|
||||
|
||||
// A list cached by an older version has no status field, and a row the
|
||||
// server defaulted has "reading" — both mean the same thing here.
|
||||
function statusOf(b) {
|
||||
return b.status || "reading";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Mutations (optimistic: update UI/cache first, then sync)
|
||||
// ============================================================
|
||||
@@ -415,6 +428,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Archive parks a series: it leaves All and Favourites but the server keeps
|
||||
// polling it for new chapters. "finished" is deliberately not reachable from
|
||||
// here — the API rejects that value, it is a web-UI decision.
|
||||
async function toggleArchive(key) {
|
||||
const existing = state.byKey[key];
|
||||
if (!existing) return;
|
||||
const next = statusOf(existing) === "archived" ? "reading" : "archived";
|
||||
const bm = Object.assign({}, existing, { status: next });
|
||||
upsertLocal(bm);
|
||||
render();
|
||||
try {
|
||||
const saved = await apiPut(bm.key, bm, { sendStatus: true });
|
||||
upsertLocal(saved); // adopt the stored row: the server owns updated_at
|
||||
render();
|
||||
toast(next === "archived" ? "Archived" : "Back in your list");
|
||||
} catch (e) {
|
||||
toast("Archive failed — retry when online", true);
|
||||
}
|
||||
}
|
||||
|
||||
// Records the newest chapter a site has published. Silent: this fires from
|
||||
// page visits and background checks the user did not ask for, and it never
|
||||
// reorders the list — updated_at is a candidate the server discards unless
|
||||
@@ -462,6 +495,7 @@
|
||||
const now = Date.now();
|
||||
const due = state.list
|
||||
.filter((b) => b.site === site && b.series_url)
|
||||
.filter((b) => statusOf(b) !== "finished")
|
||||
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
||||
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
||||
.slice(0, LATEST_CHECK_BATCH);
|
||||
@@ -551,7 +585,7 @@
|
||||
// ============================================================
|
||||
|
||||
let root, panelOpen = false;
|
||||
let activeTab = "all"; // "all" | "favorites"; resets each page, by design
|
||||
let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design
|
||||
|
||||
function buildUI() {
|
||||
const host = document.createElement("div");
|
||||
@@ -580,7 +614,7 @@
|
||||
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
||||
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
||||
|
||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"]]) {
|
||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||
root.getElementById(id).addEventListener("click", () => {
|
||||
activeTab = tab;
|
||||
render();
|
||||
@@ -752,22 +786,28 @@
|
||||
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." }));
|
||||
}
|
||||
|
||||
// Bookmark list. Favorites only narrows what is drawn — state.list keeps
|
||||
// every bookmark, so favouriting one never removes it from "All".
|
||||
const favorites = activeTab === "favorites";
|
||||
root.getElementById("tabAll").classList.toggle("active", !favorites);
|
||||
root.getElementById("tabFav").classList.toggle("active", favorites);
|
||||
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
||||
// Archived rows are hidden from All and Favourites, and finished ones —
|
||||
// which only the web UI can set — are hidden from every tab.
|
||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
||||
}
|
||||
|
||||
const listEl = root.getElementById("list");
|
||||
listEl.innerHTML = "";
|
||||
const items = favorites ? state.list.filter((b) => b.favorite) : state.list;
|
||||
const items = state.list.filter((b) => {
|
||||
const s = statusOf(b);
|
||||
if (activeTab === "archived") return s === "archived";
|
||||
if (s !== "reading") return false;
|
||||
return activeTab !== "favorites" || b.favorite;
|
||||
});
|
||||
if (items.length === 0) {
|
||||
listEl.appendChild(
|
||||
el("div", {
|
||||
class: "empty",
|
||||
text: favorites ? "No favourites yet." : "No bookmarks yet.",
|
||||
})
|
||||
);
|
||||
const empty = {
|
||||
favorites: "No favourites yet.",
|
||||
archived: "Nothing archived.",
|
||||
all: "No bookmarks yet.",
|
||||
}[activeTab];
|
||||
listEl.appendChild(el("div", { class: "empty", text: empty }));
|
||||
}
|
||||
for (const b of items) {
|
||||
listEl.appendChild(renderItem(b));
|
||||
@@ -798,6 +838,11 @@
|
||||
title: b.favorite ? "Remove from favourites" : "Add to favourites",
|
||||
onclick: () => toggleFavorite(b.key),
|
||||
}),
|
||||
el("button", {
|
||||
class: "btn small",
|
||||
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
|
||||
onclick: () => toggleArchive(b.key),
|
||||
}),
|
||||
el("a", { class: "btn small", href: cont, text: "Continue" }),
|
||||
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
|
||||
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
|
||||
@@ -909,10 +954,16 @@
|
||||
<span>Manga Bookmarks</span>
|
||||
<button id="closeBtn" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div id="nav">
|
||||
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a>
|
||||
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
||||
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
||||
</div>
|
||||
<section id="context"></section>
|
||||
<div id="tabs" role="tablist">
|
||||
<button id="tabAll" class="tab active" role="tab">All</button>
|
||||
<button id="tabFav" class="tab" role="tab">★ Favourites</button>
|
||||
<button id="tabArc" class="tab" role="tab">Archived</button>
|
||||
</div>
|
||||
<div id="list"></div>
|
||||
</aside>
|
||||
@@ -960,6 +1011,13 @@
|
||||
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
|
||||
}
|
||||
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
|
||||
#nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; }
|
||||
.chip {
|
||||
background: #2a2a33; color: #c4b5fd; text-decoration: none;
|
||||
padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip:active { opacity: .8; }
|
||||
#context {
|
||||
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user