Offline retry queue for userscript writes (#5)
## The bug
Every mutation in the userscript is optimistic: it writes `state.list` and the `mangabm:cache` copy, re-renders, then PUTs. **If the PUT fails, nothing rolls back and nothing retries.** The cache now asserts something the server has never heard of, until the next successful `GET /bookmarks` silently overwrites it.
Walked end to end: phone loses signal mid-read, user taps **Archive**, the card moves to Archived and looks saved. `apiPut` throws. Local state is not rolled back. Signal returns, a later navigation calls `refresh()` → `apiGet()` → `setList()`, which replaces `state.list` wholesale. The series is back in All. No toast, no explanation, minutes later.
Four of the five call sites already toasted a promise of a retry that did not exist. This makes the existing copy honest rather than adding a new promise.
## The shape
**Markers, not payloads.** Every mutation already builds and PUTs the *whole* desired row, and `state.list` (mirrored into `mangabm:cache`) already *is* the desired state. So the queue stores only `{key, op, sendStatus, attempts}` in `localStorage` under `mangabm:queue`; the body is read from `state.byKey` at send time. That collapses four hard questions at once:
- **Ordering** — one entry per key, so two writes to the same series cannot replay out of order.
- **Coalescing** — archive-then-unarchive is not two writes, it is "the cache now says `reading`". Nothing to merge.
- **DELETE after PUT** — the delete entry *replaces* the put entry, so a replay cannot resurrect the row.
- **Staleness** — no snapshot can drift from the cache, because there is no snapshot.
**One write path.** `pushBookmark` / `pushDelete` are the only way a user-facing mutation reaches the API — not a fallback bolted onto each `catch`. That distinction is the whole point; see below.
**Drain triggers**, all cheap when the queue is empty (`drain()` returns on its first line): head of `refresh()`, `onNavigate` (drain only, *not* a full refresh — Asura is client-routed and an extra GET per route change is not wanted), a `window` `online` listener, and tapping the pending chip.
**Visibility.** A `⟳ N pending` chip in the existing `#nav` row, hidden entirely when the queue is empty. Silent convergence in the happy path; honest the moment something is stuck.
**Failure classes:** a `400` drops the entry and says so; a `401` aborts the whole pass and keeps the queue intact (fixing the token fixes everything); a `404` on DELETE is treated as success; network errors and `5xx` retry to a cap of 10 attempts. Every dropped write is announced — a queue that fails permanently and says nothing is the same class of bug being fixed.
## The sticky-`sendStatus` hole this closes
An empty `status` on the wire means "keep the stored bucket" server-side. A queue bolted onto each mutation's `catch` has a hole:
1. Offline. User archives X → entry `{X, put, sendStatus: true}` is queued.
2. Signal returns. No drain trigger has fired yet.
3. User reads a chapter of X → `syncUpsert` PUTs with `sendStatus: false` → **succeeds** → `upsertLocal(saved)` adopts a server row that still says `reading`.
4. The archive is gone from local state, and the pending entry now replays a row that no longer carries the intent. Silent un-archive.
Routing every write through `pushBookmark` — which ORs in any pending `sendStatus` and only ever *widens* it, never narrows it — is what closes that. A replayed progress write still omits `status`; a replayed archive still carries it.
`refresh()` drains before it fetches, then `overlayPending()` re-applies anything still pending over the fetched list before `setList` replaces `state.byKey`, so the card the user just changed never flaps back.
`applyLatestChapterIfChanged` deliberately stays **out** of the queue: it is background information the user never asked for, the server-side poller learns the same fact independently, and `backgroundRefreshLatest` already retries on a 4h throttle. Queueing it would let a stale local `latest_chapter` overwrite a fresher poller value on replay.
## Fixes from the final review (commits 6-8)
The whole-branch review found one Critical and two Important defects that only appear across commit boundaries:
- **Critical — the un-archive hole reopened through the latest-chapter exclusion.** `applyLatestChapterIfChanged` PUTs without `sendStatus`, so the server strips `status`, returns the stored `reading`, and `upsertLocal(saved)` writes that over a pending archive. `onNavigate` runs `maybeCaptureLatestOnSeriesPage()` *before* `drain()` with no await between them, so this was deterministic on any series-page visit, not a race — and the drain then sent `status:"reading"` explicitly, making it permanent. Fixed with a single `if (queueGet(bm.key)) return;` guard: the write stays unqueued as designed, it just no longer adopts a server row while a write is pending. `latest_chapter` still reaches the server via the drain, carrying the correct bucket.
- **Important — `draining` guarded drain-vs-drain but not drain-vs-mutation.** A tap during an in-flight same-key PUT started a second concurrent write; whichever response landed second won, and the loser's `queueDrop` could delete the entry the tap had just parked. Fixed with per-key in-flight tracking: a write for a key already in flight defers (parks a queue entry, sends nothing, returns `false` so the caller still toasts), and the landing flight suppresses its own `upsertLocal`/`queueDrop` when superseded — including on its failure path, so a failing flight cannot clobber a parked `op:"delete"` and resurrect a removed bookmark.
- **Important — `drain()` returned `undefined` while already draining**, so `refresh()`'s `await drain()` was a silent no-op and could adopt a pre-write list, flapping the card at boot. It now returns the in-flight promise. The empty-queue fast path is unchanged and still an immediate return.
Also: `render()` moved out of `pushBookmark`'s `try` (a render throw was re-queueing an already-successful write), and unawaited `drain()` rejections are swallowed.
## The backend is untouched
No file under `backend/` is in this diff. The `updated_at` rule and the empty-status keep rule stay solely in `Store.Upsert`; nothing client-side duplicates or works around them. A replayed PUT is an ordinary late write under the project's existing last-write-wins model. Regression check: `go test ./...` is `ok`, `CGO_ENABLED=0 go build ./...` succeeds.
Two accepted losses, marked with `ponytail:` comments at the replay site: a `latest_chapter` the poller learned while the client was offline can be overwritten by the client's older value (self-healing on the poller's next cooldown), and read progress made on another device between the failed write and the replay can be overwritten (single-user deployment).
## Verification status — read this before merging
`node --check` passes and every commit was reviewed, but **the 15-row manual DevTools checklist has NOT been run.** There is no test infrastructure for the userscript, and by design it gains none here — a pasted copy of the logic in a scratch node script would drift from the real file the moment either changed. The author is shipping to prod and verifying there.
The rows most worth checking first, because each maps to a specific defect the review caught:
- Offline → archive X → online → open X's **series page** and nothing else → X must stay Archived. *(the Critical above; nothing else exercises it)*
- Slow 3G → queue a write → tap the pending chip → immediately archive the same series → final state must match the last tap.
- Queue a write → reload on a slow link → the card must not flap back during `init`.
- Offline → archive X, then remove X → one entry, `op:"delete"` → after reconnecting, X must not reappear.
- Empty queue → navigate for a minute → no chip and **no extra network requests** from `onNavigate`.
## Known limitations, deliberately not fixed here
- A `400` drops the entry and toasts, but local state keeps asserting the lost change until the next successful `GET`.
- A `401` on a live tap toasts "will sync when online" rather than the auth message; the user learns the truth on the next drain.
- Two same-origin tabs clobber each other's `mangabm:queue` — the queue is read once at boot and each save writes the whole array. Same idiom as the pre-existing `saveCache`; low risk on mobile Bromite.
- A pending favourite floats to the top of the list until it syncs, then settles back. Consistent with how optimistic writes already behaved.
Reviewed-on: #5
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #5.
This commit is contained in:
@@ -78,11 +78,24 @@ 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
|
4. **Retry queue** — every write goes through `pushBookmark`/`pushDelete`, so a
|
||||||
|
failed mutation is parked in `localStorage` (`mangabm:queue`) and replayed on
|
||||||
|
the next navigation, reconnect, or `refresh()`. Entries are markers
|
||||||
|
(`{key, op, sendStatus, attempts}`), never payloads — the body is read from
|
||||||
|
the cache at send time, so one entry per key gives ordering and coalescing for
|
||||||
|
free. `sendStatus` is **sticky**: while an archive is pending, later writes to
|
||||||
|
that key keep carrying the bucket, which is what stops a successful
|
||||||
|
in-between write from silently un-archiving the series. `refresh()` drains
|
||||||
|
before it fetches and overlays anything still pending, so the list never
|
||||||
|
flaps. A 400 drops the entry, a 401 aborts the pass and keeps the queue, and
|
||||||
|
transient failures retry to a cap of 10. Latest-chapter writes deliberately
|
||||||
|
stay out of the queue. See
|
||||||
|
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
||||||
|
5. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS
|
||||||
(critical on mobile). Three tabs (All / Favourites / Archived) and a row of
|
(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
|
link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG
|
||||||
block next to `API_BASE`.
|
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).
|
6. **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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Manga Bookmark Sync
|
// @name Manga Bookmark Sync
|
||||||
// @namespace mangabm
|
// @namespace mangabm
|
||||||
// @version 1.3.0
|
// @version 1.4.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/*
|
||||||
@@ -265,6 +265,14 @@
|
|||||||
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The retry queue treats a 400 differently from a dropped connection, so the
|
||||||
|
// status has to survive the throw. A network failure leaves it undefined.
|
||||||
|
function httpError(what, status) {
|
||||||
|
const e = new Error(what + " " + status);
|
||||||
|
e.status = status;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
async function apiGet() {
|
async function apiGet() {
|
||||||
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
|
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
|
||||||
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
|
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
|
||||||
@@ -283,7 +291,7 @@
|
|||||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
if (!res.ok) throw httpError("PUT /bookmarks", res.status);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +300,8 @@
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("DELETE /bookmarks " + res.status);
|
// 404 means the row is already gone, which is what the caller wanted.
|
||||||
|
if (!res.ok && res.status !== 404) throw httpError("DELETE /bookmarks", res.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -338,10 +347,250 @@
|
|||||||
return b.status || "reading";
|
return b.status || "reading";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Retry queue
|
||||||
|
//
|
||||||
|
// A failed write is not rolled back and not lost: the key is parked here and
|
||||||
|
// replayed when the backend is next reachable. Entries carry no payload — the
|
||||||
|
// body is read from state.byKey at send time, because state.list *is* the
|
||||||
|
// desired state and is already persisted in mangabm:cache. One entry per key,
|
||||||
|
// so two writes to the same series cannot replay out of order, an
|
||||||
|
// archive-then-unarchive collapses to whatever the cache now says, and a
|
||||||
|
// queued DELETE replaces a queued PUT rather than racing it.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const QUEUE_KEY = "mangabm:queue";
|
||||||
|
const QUEUE_MAX = 200; // ~12 KB; realistically bounded by the bookmark count
|
||||||
|
const QUEUE_MAX_ATTEMPTS = 10;
|
||||||
|
|
||||||
|
let authFailed = false; // a 401 was seen; retrying cannot help until the token changes
|
||||||
|
|
||||||
|
function loadQueue() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(QUEUE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveQueue(q) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(QUEUE_KEY, JSON.stringify(q));
|
||||||
|
} catch (e) {
|
||||||
|
/* quota / private mode — ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = loadQueue();
|
||||||
|
|
||||||
|
function queueCount() {
|
||||||
|
return queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueGet(key) {
|
||||||
|
return queue.find((e) => e.key === key) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFor(key) {
|
||||||
|
const b = state.byKey[key];
|
||||||
|
return (b && (b.title || b.series_id)) || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest goes first when the cap is hit: the newest entry is the action the
|
||||||
|
// user just took, and dropping that is the bug this queue exists to fix.
|
||||||
|
function queuePush(entry) {
|
||||||
|
if (queue.length >= QUEUE_MAX) {
|
||||||
|
const evicted = queue.shift();
|
||||||
|
toast("Sync queue full — dropped " + titleFor(evicted.key), true);
|
||||||
|
}
|
||||||
|
queue.push(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueDrop(key) {
|
||||||
|
const i = queue.findIndex((e) => e.key === key);
|
||||||
|
if (i >= 0) {
|
||||||
|
queue.splice(i, 1);
|
||||||
|
saveQueue(queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parks a write without counting it as a failure. Used when the key is already
|
||||||
|
// on the wire: the write is owed, but nothing went wrong, so it must not spend
|
||||||
|
// one of the ten attempts.
|
||||||
|
function queueDefer(key, op, sendStatus) {
|
||||||
|
const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 });
|
||||||
|
e.op = op; // a delete replaces a put, and a put replaces a delete
|
||||||
|
e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped
|
||||||
|
saveQueue(queue);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records a failed write and classifies why it failed. A 400 is a payload the
|
||||||
|
// server will never accept, so it is dropped now instead of being retried ten
|
||||||
|
// times; a 401 is the wrong token, so the entry is kept untouched and the
|
||||||
|
// drain gives up until the token changes.
|
||||||
|
function queueEnqueue(key, op, sendStatus, err) {
|
||||||
|
const status = err && err.status;
|
||||||
|
if (status === 400) {
|
||||||
|
queueDrop(key);
|
||||||
|
toast("Couldn't sync " + titleFor(key) + " — change lost", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const e = queueDefer(key, op, sendStatus);
|
||||||
|
if (status === 401) {
|
||||||
|
authFailed = true;
|
||||||
|
} else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) {
|
||||||
|
queueDrop(key);
|
||||||
|
toast("Gave up syncing " + titleFor(key), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveQueue(queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys with a request on the wire right now, mapped to "another write arrived
|
||||||
|
// while this one was flying". A second write to the same key is never sent
|
||||||
|
// concurrently — the two responses would race to own the row — it is parked in
|
||||||
|
// the queue instead and a later drain replays it. The flight already in the
|
||||||
|
// air must then leave that entry alone and not adopt its own now-stale
|
||||||
|
// response, or it would undo the write the user just made.
|
||||||
|
const inFlight = new Map();
|
||||||
|
|
||||||
|
let draining = null; // the pass in progress, so a second caller awaits it
|
||||||
|
|
||||||
|
// Replays everything owed. Cheap in the normal case — it returns on the first
|
||||||
|
// line when the queue is empty, which is why it can hang off navigation. A
|
||||||
|
// 401 stops the whole pass: the token is wrong, so the next entry would fail
|
||||||
|
// the same way, and the queue is left intact so fixing the token fixes it.
|
||||||
|
//
|
||||||
|
// Returns the in-flight pass when one is already running, so refresh()'s
|
||||||
|
// `await drain()` really does wait for what we owe instead of racing a drain
|
||||||
|
// that onNavigate or the online listener started unawaited.
|
||||||
|
function drain() {
|
||||||
|
if (queue.length === 0) return Promise.resolve();
|
||||||
|
if (draining) return draining;
|
||||||
|
const pass = (async () => {
|
||||||
|
authFailed = false;
|
||||||
|
try {
|
||||||
|
for (const e of queue.slice()) {
|
||||||
|
if (e.op === "delete") await pushDelete(e.key);
|
||||||
|
else await pushBookmark(e.key, e.sendStatus);
|
||||||
|
if (authFailed) {
|
||||||
|
toast("Sync auth failed — check the token", true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// Swallowed, not surfaced: drain is called unawaited from onNavigate and the
|
||||||
|
// online listener, and an uncaught rejection on a page we do not control is
|
||||||
|
// a console error nobody can act on. Every real sync failure is already
|
||||||
|
// toasted and queued by pushBookmark/pushDelete.
|
||||||
|
draining = pass
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
draining = null;
|
||||||
|
});
|
||||||
|
return draining;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys the server has not heard about yet must survive a fetched list, or the
|
||||||
|
// card the user just changed silently flaps back — the exact bug this queue
|
||||||
|
// exists to fix. Reads state.byKey, so it must run *before* setList replaces
|
||||||
|
// it.
|
||||||
|
function overlayPending(list) {
|
||||||
|
if (queue.length === 0) return list;
|
||||||
|
const out = list.filter((b) => {
|
||||||
|
const e = queueGet(b.key);
|
||||||
|
return !(e && e.op === "delete");
|
||||||
|
});
|
||||||
|
for (const e of queue) {
|
||||||
|
if (e.op !== "put") continue;
|
||||||
|
const local = state.byKey[e.key];
|
||||||
|
if (!local) continue;
|
||||||
|
const i = out.findIndex((b) => b.key === e.key);
|
||||||
|
if (i >= 0) out[i] = local;
|
||||||
|
else out.push(local);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Mutations (optimistic: update UI/cache first, then sync)
|
// Mutations (optimistic: update UI/cache first, then sync)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
// Every write goes through here, queued or not, so there is one place that
|
||||||
|
// talks to the API and one place that decides `sendStatus`. That flag is
|
||||||
|
// sticky: while an archive is pending for a key, a later progress write to the
|
||||||
|
// same key still carries the bucket — otherwise the server would answer with
|
||||||
|
// the old status and the adopted row would silently un-archive the series.
|
||||||
|
async function pushBookmark(key, sendStatus) {
|
||||||
|
const pending = queueGet(key);
|
||||||
|
const withStatus = sendStatus || (pending ? pending.sendStatus : false);
|
||||||
|
const bm = state.byKey[key];
|
||||||
|
if (!bm) {
|
||||||
|
queueDrop(key); // removed locally in the meantime — nothing left to send
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (inFlight.has(key)) {
|
||||||
|
queueDefer(key, "put", withStatus); // see inFlight — parked, not sent
|
||||||
|
inFlight.set(key, true); // supersedes the flight already in the air
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inFlight.set(key, false);
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
// ponytail: last-write-wins, so a replay can overwrite a newer server
|
||||||
|
// value (a poller-written latest_chapter, or progress from another
|
||||||
|
// device). Single user, self-healing on the next poll — revisit only if
|
||||||
|
// this ever runs multi-user.
|
||||||
|
const saved = await apiPut(key, bm, { sendStatus: withStatus });
|
||||||
|
if (!inFlight.get(key)) {
|
||||||
|
upsertLocal(saved);
|
||||||
|
queueDrop(key);
|
||||||
|
}
|
||||||
|
ok = true;
|
||||||
|
} catch (e) {
|
||||||
|
// Same guard as the success path: a newer write is parked for this key, so
|
||||||
|
// classifying *this* failure would clobber its op or, on a 400, drop it
|
||||||
|
// outright. Its own drain reports its own outcome. The one thing that must
|
||||||
|
// still carry across is sendStatus — this flight may have been the archive
|
||||||
|
// replay, and losing its flag here would narrow the parked write into a
|
||||||
|
// silent un-archive.
|
||||||
|
const parked = inFlight.get(key) ? queueGet(key) : null;
|
||||||
|
if (parked) queueDefer(key, parked.op, withStatus);
|
||||||
|
else queueEnqueue(key, "put", withStatus, e);
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(key);
|
||||||
|
}
|
||||||
|
// Outside the try: a throw in render() is a UI bug, not a write failure, and
|
||||||
|
// must not re-queue a write that already landed.
|
||||||
|
if (ok) render();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushDelete(key) {
|
||||||
|
if (inFlight.has(key)) {
|
||||||
|
queueDefer(key, "delete", false); // see inFlight — parked, not sent
|
||||||
|
inFlight.set(key, true); // supersedes the flight already in the air
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inFlight.set(key, false);
|
||||||
|
try {
|
||||||
|
await apiDelete(key);
|
||||||
|
if (!inFlight.get(key)) queueDrop(key);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (!inFlight.get(key)) queueEnqueue(key, "delete", false, e); // see pushBookmark
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bookmarkCurrent() {
|
async function bookmarkCurrent() {
|
||||||
const p = state.page;
|
const p = state.page;
|
||||||
if (p.type !== "series" && p.type !== "chapter") return;
|
if (p.type !== "series" && p.type !== "chapter") return;
|
||||||
@@ -400,14 +649,8 @@
|
|||||||
async function syncUpsert(bm, okMsg) {
|
async function syncUpsert(bm, okMsg) {
|
||||||
upsertLocal(bm); // optimistic
|
upsertLocal(bm); // optimistic
|
||||||
render();
|
render();
|
||||||
try {
|
if (await pushBookmark(bm.key, false)) toast(okMsg);
|
||||||
const saved = await apiPut(bm.key, bm);
|
else toast("Saved — will sync when online", true);
|
||||||
upsertLocal(saved); // adopt server updated_at
|
|
||||||
render();
|
|
||||||
toast(okMsg);
|
|
||||||
} catch (e) {
|
|
||||||
toast("Offline — saved locally, will retry", true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFavorite(key) {
|
async function toggleFavorite(key) {
|
||||||
@@ -419,13 +662,7 @@
|
|||||||
});
|
});
|
||||||
upsertLocal(bm); // optimistic
|
upsertLocal(bm); // optimistic
|
||||||
render();
|
render();
|
||||||
try {
|
if (!(await pushBookmark(bm.key, false))) toast("Saved — will sync when online", true);
|
||||||
const saved = await apiPut(bm.key, bm);
|
|
||||||
upsertLocal(saved);
|
|
||||||
render();
|
|
||||||
} catch (e) {
|
|
||||||
toast("Offline — saved locally, will retry", true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive parks a series: it leaves All and Favourites but the server keeps
|
// Archive parks a series: it leaves All and Favourites but the server keeps
|
||||||
@@ -435,16 +672,14 @@
|
|||||||
const existing = state.byKey[key];
|
const existing = state.byKey[key];
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const next = statusOf(existing) === "archived" ? "reading" : "archived";
|
const next = statusOf(existing) === "archived" ? "reading" : "archived";
|
||||||
const bm = Object.assign({}, existing, { status: next });
|
upsertLocal(Object.assign({}, existing, { status: next }));
|
||||||
upsertLocal(bm);
|
|
||||||
render();
|
render();
|
||||||
try {
|
// The only write with an opinion about the bucket, so the only one that
|
||||||
const saved = await apiPut(bm.key, bm, { sendStatus: true });
|
// sends `status` at all.
|
||||||
upsertLocal(saved); // adopt the stored row: the server owns updated_at
|
if (await pushBookmark(key, true)) {
|
||||||
render();
|
|
||||||
toast(next === "archived" ? "Archived" : "Back in your list");
|
toast(next === "archived" ? "Archived" : "Back in your list");
|
||||||
} catch (e) {
|
} else {
|
||||||
toast("Archive failed — retry when online", true);
|
toast((next === "archived" ? "Archived" : "Restored") + " — will sync when online", true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +697,9 @@
|
|||||||
});
|
});
|
||||||
upsertLocal(bm);
|
upsertLocal(bm);
|
||||||
render();
|
render();
|
||||||
|
// A queued write owns this row; the drain sends latest_chapter
|
||||||
|
// with it, carrying the correct bucket.
|
||||||
|
if (queueGet(bm.key)) return;
|
||||||
try {
|
try {
|
||||||
const saved = await apiPut(bm.key, bm);
|
const saved = await apiPut(bm.key, bm);
|
||||||
upsertLocal(saved);
|
upsertLocal(saved);
|
||||||
@@ -521,12 +759,8 @@
|
|||||||
async function removeBookmark(key) {
|
async function removeBookmark(key) {
|
||||||
removeLocal(key); // optimistic
|
removeLocal(key); // optimistic
|
||||||
render();
|
render();
|
||||||
try {
|
if (await pushDelete(key)) toast("Removed");
|
||||||
await apiDelete(key);
|
else toast("Removed — will sync when online", true);
|
||||||
toast("Removed");
|
|
||||||
} catch (e) {
|
|
||||||
toast("Offline — remove will retry", true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
|
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
|
||||||
@@ -613,6 +847,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);
|
||||||
|
root.getElementById("pending").addEventListener("click", () => drain());
|
||||||
|
|
||||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||||
root.getElementById(id).addEventListener("click", () => {
|
root.getElementById(id).addEventListener("click", () => {
|
||||||
@@ -765,6 +1000,11 @@
|
|||||||
root.getElementById("panel").classList.toggle("open", panelOpen);
|
root.getElementById("panel").classList.toggle("open", panelOpen);
|
||||||
root.getElementById("backdrop").classList.toggle("open", panelOpen);
|
root.getElementById("backdrop").classList.toggle("open", panelOpen);
|
||||||
|
|
||||||
|
// Silent while everything is synced; honest the moment something is stuck.
|
||||||
|
const pending = root.getElementById("pending");
|
||||||
|
pending.hidden = queueCount() === 0;
|
||||||
|
pending.textContent = "⟳ " + queueCount() + " pending";
|
||||||
|
|
||||||
// Context header for the current page.
|
// Context header for the current page.
|
||||||
const ctx = root.getElementById("context");
|
const ctx = root.getElementById("context");
|
||||||
ctx.innerHTML = "";
|
ctx.innerHTML = "";
|
||||||
@@ -877,9 +1117,10 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
await drain(); // push what we owe before adopting the server's view of it
|
||||||
try {
|
try {
|
||||||
const list = await apiGet();
|
const list = await apiGet();
|
||||||
setList(list);
|
setList(overlayPending(list));
|
||||||
render();
|
render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
render(); // fall back to cache
|
render(); // fall back to cache
|
||||||
@@ -895,6 +1136,9 @@
|
|||||||
// Asura is client-routed, so full loads are rare — checking here too is
|
// Asura is client-routed, so full loads are rare — checking here too is
|
||||||
// what keeps its bookmarks current. The throttle still caps the rate.
|
// what keeps its bookmarks current. The throttle still caps the rate.
|
||||||
backgroundRefreshLatest();
|
backgroundRefreshLatest();
|
||||||
|
// Drain only, not a full refresh: Asura may go a long time without a
|
||||||
|
// reload, and an extra GET per client-side route change is not wanted.
|
||||||
|
drain();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
|
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
|
||||||
@@ -930,6 +1174,7 @@
|
|||||||
state.page = detect();
|
state.page = detect();
|
||||||
render();
|
render();
|
||||||
installNavWatcher();
|
installNavWatcher();
|
||||||
|
window.addEventListener("online", drain); // signal returned while the page stayed open
|
||||||
// Sync first: both auto-record and the latest-chapter checks below need to
|
// Sync first: both auto-record and the latest-chapter checks below need to
|
||||||
// know which series are bookmarked and how fresh they are.
|
// know which series are bookmarked and how fresh they are.
|
||||||
refresh().then(() => {
|
refresh().then(() => {
|
||||||
@@ -958,6 +1203,7 @@
|
|||||||
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a>
|
<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://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
||||||
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
||||||
|
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
|
||||||
</div>
|
</div>
|
||||||
<section id="context"></section>
|
<section id="context"></section>
|
||||||
<div id="tabs" role="tablist">
|
<div id="tabs" role="tablist">
|
||||||
@@ -1018,6 +1264,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.chip:active { opacity: .8; }
|
.chip:active { opacity: .8; }
|
||||||
|
.chip.pending { background: #4c1d95; border: none; cursor: pointer; }
|
||||||
|
.chip.pending[hidden] { display: none; }
|
||||||
#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;
|
||||||
|
|||||||
Reference in New Issue
Block a user