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
15 changed files with 776 additions and 42 deletions
+1
View File
@@ -3,6 +3,7 @@
*.db-shm *.db-shm
*.db-wal *.db-wal
backend/server backend/server
backend/backend
.playwright-mcp/ .playwright-mcp/
graphify-out/ graphify-out/
plans/ plans/
+14 -1
View File
@@ -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 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. 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. - **`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` - **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH`
(default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD` (default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD`
(gates the browser UI; unset disables it), (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. 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. 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. 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). 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) ### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
+9
View File
@@ -148,6 +148,15 @@ desktop for faster testing — install the same file unchanged.
- **Favourites**: the ☆ on any row toggles it; the **★ Favourites** tab narrows - **Favourites**: the ☆ on any row toggles it; the **★ Favourites** tab narrows
the list. Favourited series still appear under **All**. The flag syncs, so it the list. Favourited series still appear under **All**. The flag syncs, so it
follows you across devices; the chosen tab does not persist. 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 - Bookmarks made on Asura appear when the panel is opened on Demonic, and vice
versa — the backend is the shared store. versa — the backend is the shared store.
+15
View File
@@ -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 // Candidate timestamp, not a decision: Upsert keeps the stored one unless
// reading progress actually moved. Any client value is ignored. // reading progress actually moved. Any client value is ignored.
b.UpdatedAt = time.Now().UnixMilli() b.UpdatedAt = time.Now().UnixMilli()
+7
View File
@@ -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 // Re-read: the row may have been updated or deleted while the fetch was in
// flight, and writing b back wholesale would undo that. // 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) cur, found, err := p.store.Get(b.Key)
if err != nil { if err != nil {
log.Printf("latest poll %q: reread: %v", b.Key, err) log.Printf("latest poll %q: reread: %v", b.Key, err)
+83
View File
@@ -1,6 +1,11 @@
package main package main
import ( import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"time" "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)
}
}
+11 -2
View File
@@ -131,10 +131,19 @@ button, .primary, [role="tab"] {
.search:focus-visible { outline: 2px solid var(--accent); } .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"] { .tabs [role="tab"] {
flex: 1; flex: 1 0 auto;
padding-inline: 14px;
white-space: nowrap;
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--surface); background: var(--surface);
+44 -4
View File
@@ -27,6 +27,10 @@ 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.
// 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. // HasNewChapter reports whether the site has published past the read point.
@@ -45,6 +49,13 @@ func (b Bookmark) ContinueURL() string {
return b.SeriesURL return b.SeriesURL
} }
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
const (
statusReading = "reading"
statusArchived = "archived"
statusFinished = "finished"
)
const schema = ` const schema = `
CREATE TABLE IF NOT EXISTS bookmarks ( CREATE TABLE IF NOT EXISTS bookmarks (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -60,6 +71,7 @@ CREATE TABLE IF NOT EXISTS bookmarks (
latest_chapter TEXT NOT NULL DEFAULT '', latest_chapter TEXT NOT NULL DEFAULT '',
latest_chapter_num REAL, latest_chapter_num REAL,
latest_checked_at INTEGER NOT NULL DEFAULT 0, latest_checked_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'reading',
updated_at INTEGER NOT NULL 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 // sorts first so a new bookmark is picked up on the next tick with no
// special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked. // special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked.
{"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`}, {"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, const bookmarkColumns = `key, site, series_id, title, series_url, cover,
last_chapter, last_chapter_num, last_chapter_url, 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. // Store is the SQLite-backed bookmark store.
type Store struct { type Store struct {
@@ -156,13 +171,14 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
b Bookmark b Bookmark
title, seriesURL, cover sql.NullString title, seriesURL, cover sql.NullString
lastChapter, lastChapterURL, latestChapter sql.NullString lastChapter, lastChapterURL, latestChapter sql.NullString
status sql.NullString
lastChapterNum, latestChapterNum sql.NullFloat64 lastChapterNum, latestChapterNum sql.NullFloat64
favorite sql.NullInt64 favorite sql.NullInt64
) )
if err := scan( if err := scan(
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover, &b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
&lastChapter, &lastChapterNum, &lastChapterURL, &lastChapter, &lastChapterNum, &lastChapterURL,
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status,
); err != nil { ); err != nil {
return Bookmark{}, err return Bookmark{}, err
} }
@@ -177,6 +193,13 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
if latestChapterNum.Valid { if latestChapterNum.Valid {
b.LatestChapterNum = &latestChapterNum.Float64 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 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 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 // 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. // 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(` if _, err := tx.Exec(`
INSERT INTO bookmarks (`+bookmarkColumns+`) INSERT INTO bookmarks (`+bookmarkColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading'))
ON CONFLICT(key) DO UPDATE SET ON CONFLICT(key) DO UPDATE SET
site=excluded.site, series_id=excluded.series_id, title=excluded.title, site=excluded.site, series_id=excluded.series_id, title=excluded.title,
series_url=excluded.series_url, cover=excluded.cover, series_url=excluded.series_url, cover=excluded.cover,
@@ -253,6 +286,7 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
favorite=excluded.favorite, favorite=excluded.favorite,
latest_chapter=excluded.latest_chapter, latest_chapter=excluded.latest_chapter,
latest_chapter_num=excluded.latest_chapter_num, latest_chapter_num=excluded.latest_chapter_num,
status=excluded.status,
updated_at=CASE updated_at=CASE
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
THEN excluded.updated_at THEN excluded.updated_at
@@ -260,7 +294,8 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
END`, END`,
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
b.LastChapter, b.LastChapterNum, b.LastChapterURL, 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) 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 // Bookmarks with no series_url are skipped — there is nothing to fetch, which
// is the same filter the userscript applies at L452. // 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) { func (s *Store) DueForLatestCheck(cutoffMs int64, limit int) ([]Bookmark, error) {
rows, err := s.db.Query(`SELECT `+bookmarkColumns+` rows, err := s.db.Query(`SELECT `+bookmarkColumns+`
FROM bookmarks FROM bookmarks
WHERE series_url IS NOT NULL AND series_url <> '' WHERE series_url IS NOT NULL AND series_url <> ''
AND status IS NOT 'finished'
AND latest_checked_at <= ? AND latest_checked_at <= ?
ORDER BY latest_checked_at ASC ORDER BY latest_checked_at ASC
LIMIT ?`, cutoffMs, limit) LIMIT ?`, cutoffMs, limit)
+183
View File
@@ -722,3 +722,186 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got) 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")
}
}
+6
View File
@@ -31,6 +31,12 @@
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}" <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-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a> 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> </nav>
{{if .Recent}} {{if .Recent}}
+42
View File
@@ -38,6 +38,48 @@
<path d="M13.8 7.2l3 3"/> <path d="M13.8 7.2l3 3"/>
</svg> </svg>
</button> </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" <button class="icon danger" title="Remove" aria-label="Remove"
onclick="toggleConfirmRow('{{.Key}}')"> onclick="toggleConfirmRow('{{.Key}}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
+4
View File
@@ -5,6 +5,10 @@
<p class="empty">No favourites yet. Star a series to pin it here.</p> <p class="empty">No favourites yet. Star a series to pin it here.</p>
{{else if eq .Tab "new"}} {{else if eq .Tab "new"}}
<p class="empty">Nothing new. Every series is caught up to its latest chapter.</p> <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}} {{else}}
<p class="empty"> <p class="empty">
Nothing here yet. Bookmarks appear once the userscript records a chapter. Nothing here yet. Bookmarks appear once the userscript records a chapter.
+63 -16
View File
@@ -79,6 +79,7 @@ func (h *webHandler) register(mux *http.ServeMux) {
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList)) 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}/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("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete)) 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) 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 // 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 // the recent strip from it.
// active tab, so it is built before filtering. //
// 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) { func (h *webHandler) buildListView(tab string) (listView, error) {
all, err := h.store.List() // already ordered updated_at DESC all, err := h.store.List() // already ordered updated_at DESC
if err != nil { if err != nil {
return listView{}, err 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 { if len(recent) > recentCount {
recent = recent[:recentCount] recent = recent[:recentCount]
} }
items := all var items []Bookmark
switch tab { switch tab {
case "fav": case "fav":
items = []Bookmark{} items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
for _, b := range all {
if b.Favorite {
items = append(items, b)
}
}
case "new": case "new":
items = []Bookmark{} items = filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
for _, b := range all { case "archived":
if b.HasNewChapter() { items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
items = append(items, b) case "finished":
} items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
}
default: default:
tab = "all" tab = "all"
items = reading
} }
return listView{Tab: tab, Recent: recent, Items: items}, nil 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 // saveAndRenderCard upserts and renders the row as stored. Upsert decides
// whether updated_at moves, so the argument's timestamp is only a candidate and // whether updated_at moves, so the argument's timestamp is only a candidate and
// the response must come from the return value. // 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) { func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
stored, err := h.store.Upsert(b) stored, err := h.store.Upsert(b)
if err != nil { if err != nil {
@@ -270,6 +291,32 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
h.saveAndRenderCard(w, b) 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. // uiChapter forces the read chapter to a value the user typed.
// //
// Writing the number also clears last_chapter_url: that URL points at the // Writing the number also clears last_chapter_url: that URL points at the
+219 -2
View File
@@ -308,8 +308,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) != 3 { if strings.Count(body, want) != 5 {
t.Fatalf("body has %d occurrences of %s, want 3 (favorite button, delete button, chapter form)", t.Fatalf("body has %d occurrences of %s, want 5 (favorite, archive, finish, 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"`) {
@@ -513,3 +513,220 @@ func TestUIListNewTab(t *testing.T) {
t.Fatal("new tab included a series already caught up") 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)
}
}
}
+75 -17
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name Manga Bookmark Sync // @name Manga Bookmark Sync
// @namespace mangabm // @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). // @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
// @author you // @author you
// @match https://asuracomic.net/* // @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_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN 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. // Safe in Bromite's isolated world: the page's own JS cannot read these.
const CACHE_KEY = "mangabm:cache"; const CACHE_KEY = "mangabm:cache";
@@ -270,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();
@@ -325,6 +332,12 @@
saveCache(state.list); 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) // 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 // 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 // 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 // reorders the list — updated_at is a candidate the server discards unless
@@ -462,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);
@@ -551,7 +585,7 @@
// ============================================================ // ============================================================
let root, panelOpen = false; 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() { function buildUI() {
const host = document.createElement("div"); const host = document.createElement("div");
@@ -580,7 +614,7 @@
root.getElementById("backdrop").addEventListener("click", togglePanel); root.getElementById("backdrop").addEventListener("click", togglePanel);
root.getElementById("closeBtn").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", () => { root.getElementById(id).addEventListener("click", () => {
activeTab = tab; activeTab = tab;
render(); render();
@@ -752,22 +786,28 @@
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." })); 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 // Tabs narrow what is drawn; state.list always holds every bookmark.
// every bookmark, so favouriting one never removes it from "All". // Archived rows are hidden from All and Favourites, and finished ones —
const favorites = activeTab === "favorites"; // which only the web UI can set — are hidden from every tab.
root.getElementById("tabAll").classList.toggle("active", !favorites); for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
root.getElementById("tabFav").classList.toggle("active", favorites); root.getElementById(id).classList.toggle("active", activeTab === tab);
}
const listEl = root.getElementById("list"); const listEl = root.getElementById("list");
listEl.innerHTML = ""; 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) { if (items.length === 0) {
listEl.appendChild( const empty = {
el("div", { favorites: "No favourites yet.",
class: "empty", archived: "Nothing archived.",
text: favorites ? "No favourites yet." : "No bookmarks yet.", all: "No bookmarks yet.",
}) }[activeTab];
); listEl.appendChild(el("div", { class: "empty", text: empty }));
} }
for (const b of items) { for (const b of items) {
listEl.appendChild(renderItem(b)); listEl.appendChild(renderItem(b));
@@ -798,6 +838,11 @@
title: b.favorite ? "Remove from favourites" : "Add to favourites", title: b.favorite ? "Remove from favourites" : "Add to favourites",
onclick: () => toggleFavorite(b.key), 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("a", { class: "btn small", href: cont, text: "Continue" }),
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }), el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }), el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
@@ -909,10 +954,16 @@
<span>Manga Bookmarks</span> <span>Manga Bookmarks</span>
<button id="closeBtn" aria-label="Close">✕</button> <button id="closeBtn" aria-label="Close">✕</button>
</header> </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> <section id="context"></section>
<div id="tabs" role="tablist"> <div id="tabs" role="tablist">
<button id="tabAll" class="tab active" role="tab">All</button> <button id="tabAll" class="tab active" role="tab">All</button>
<button id="tabFav" class="tab" role="tab">★ Favourites</button> <button id="tabFav" class="tab" role="tab">★ Favourites</button>
<button id="tabArc" class="tab" role="tab">Archived</button>
</div> </div>
<div id="list"></div> <div id="list"></div>
</aside> </aside>
@@ -960,6 +1011,13 @@
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px; 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; } #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 { #context {
padding: 12px 16px; border-bottom: 1px solid #33333d; padding: 12px 16px; border-bottom: 1px solid #33333d;
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 8px;