6dce9fc481
## 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>
1325 lines
48 KiB
JavaScript
1325 lines
48 KiB
JavaScript
// ==UserScript==
|
|
// @name Manga Bookmark Sync
|
|
// @namespace mangabm
|
|
// @version 1.4.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/*
|
|
// @match https://asurascans.com/*
|
|
// @match https://demonicscans.org/*
|
|
// @run-at document-idle
|
|
// @noframes
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
// ============================================================
|
|
// CONFIG — fill these in before installing.
|
|
// ============================================================
|
|
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";
|
|
|
|
// Per-device record of when each series was last checked for new chapters.
|
|
// Deliberately not synced: each device does its own checking.
|
|
const LASTCHECKED_KEY = "mangabm:lastchecked";
|
|
const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000;
|
|
const LATEST_CHECK_BATCH = 1; // series fetched per navigation
|
|
|
|
// ============================================================
|
|
// Site adapters
|
|
//
|
|
// Page type + IDs come from URL regex (most stable); title/cover come from
|
|
// og: meta tags. Verified live 2026-07-24 against asurascans.com and
|
|
// demonicscans.org — see README "Adapter reference".
|
|
// ============================================================
|
|
|
|
function meta(prop) {
|
|
const el = document.querySelector('meta[property="' + prop + '"]');
|
|
return el ? el.getAttribute("content") : null;
|
|
}
|
|
|
|
// Chapter lists are read from two places: the page we are standing on, and
|
|
// series pages fetched in the background. Both are reduced to {href, text}
|
|
// pairs so each adapter needs only one rule for picking the latest chapter.
|
|
|
|
function anchorsFromDocument(doc) {
|
|
return Array.from(doc.querySelectorAll("a[href]")).map((a) => ({
|
|
href: a.getAttribute("href") || "",
|
|
text: a.textContent || "",
|
|
}));
|
|
}
|
|
|
|
// A fetched page is raw text; DOMParser would run against markup we do not
|
|
// control, so anchors are pulled out directly instead.
|
|
function anchorsFromHTML(html) {
|
|
const re = /<a\b[^>]*\shref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
const out = [];
|
|
let m;
|
|
while ((m = re.exec(html))) {
|
|
out.push({ href: m[1], text: m[2].replace(/<[^>]*>/g, " ") });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const asura = {
|
|
site: "asura",
|
|
// asuracomic.net deep links 301 to the asurascans.com *root*, dropping the
|
|
// path, and that happens at the edge before this script gets a document —
|
|
// so those URLs cannot be handled here at all (checked 2026-07-25). It stays
|
|
// matched in case the redirect starts preserving paths again; until then,
|
|
// reach series through asurascans.com.
|
|
matches: (loc) => /(^|\.)asurascans\.com$|(^|\.)asuracomic\.net$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
// /comics/<slug-hash>/chapter/<n>
|
|
let m = path.match(/^\/comics\/([^/]+)\/chapter\/([^/?#]+)/);
|
|
if (m) {
|
|
const num = parseFloat(m[2]);
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/comics/" + m[1],
|
|
chapterLabel: "Chapter " + m[2],
|
|
chapterNum: isNaN(num) ? null : num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// /comics/<slug-hash>
|
|
m = path.match(/^\/comics\/([^/?#]+)\/?$/);
|
|
if (m) {
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/comics/" + m[1],
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
|
|
// Strips "Chapter N - Read Online" and the " | Asura Scans" suffix.
|
|
function cleanTitle(t) {
|
|
if (!t) return "";
|
|
return t
|
|
.replace(/\s*Chapter\s+[\d.]+\s*-\s*Read Online\s*/i, " ")
|
|
.replace(/\s*\|\s*Asura Scans\s*$/i, "")
|
|
.trim();
|
|
}
|
|
},
|
|
// Series pages link every chapter. The link text check skips the
|
|
// "First Chapter" shortcut, which points at a chapter but is not one
|
|
// of the list entries.
|
|
latestChapterFromAnchors(anchors) {
|
|
let best = null;
|
|
for (const a of anchors) {
|
|
const m = a.href.match(/\/chapter\/([\d.]+)$/);
|
|
if (!m || !/Chapter\s+[\d.]+/i.test(a.text)) continue;
|
|
const num = parseFloat(m[1]);
|
|
if (isNaN(num)) continue;
|
|
if (!best || num > best.num) best = { num, label: "Chapter " + m[1] };
|
|
}
|
|
return best;
|
|
},
|
|
};
|
|
|
|
const demonic = {
|
|
site: "demonic",
|
|
matches: (loc) => /(^|\.)demonicscans\.org$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
// Canonical reader: /title/<slug>/chapter/<n>/<page>
|
|
// (chaptered.php?manga=<id>&chapter=<n> 301s to this before we run.)
|
|
let m = path.match(/^\/title\/([^/]+)\/chapter\/([^/?#]+)/);
|
|
if (m) {
|
|
const num = parseFloat(m[2]);
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: decodeURIComponent(m[1]),
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/manga/" + m[1],
|
|
chapterLabel: "Chapter " + m[2],
|
|
chapterNum: isNaN(num) ? null : num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// Series: /manga/<slug>
|
|
m = path.match(/^\/manga\/([^/?#]+)/);
|
|
if (m) {
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: decodeURIComponent(m[1]),
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/manga/" + m[1],
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
|
|
// Demonic chapter og:title is "<Title> Chapter N"; series is clean.
|
|
function cleanTitle(t) {
|
|
if (!t) return "";
|
|
return t.replace(/\s*Chapter\s+[\d.]+\s*$/i, "").trim();
|
|
}
|
|
},
|
|
// Series pages still link chapters through the pre-redirect chaptered.php
|
|
// form. Their order is not dependable, so take the highest number.
|
|
latestChapterFromAnchors(anchors) {
|
|
let best = null;
|
|
for (const a of anchors) {
|
|
const m = a.href.match(/chaptered\.php\?manga=\d+&(?:amp;)?chapter=([\d.]+)/);
|
|
if (!m) continue;
|
|
const num = parseFloat(m[1]);
|
|
if (isNaN(num)) continue;
|
|
if (!best || num > best.num) best = { num, label: "Chapter " + m[1] };
|
|
}
|
|
return best;
|
|
},
|
|
};
|
|
|
|
const ADAPTERS = [asura, demonic];
|
|
|
|
function detect() {
|
|
const loc = window.location;
|
|
for (const a of ADAPTERS) {
|
|
if (a.matches(loc)) return a.detect(loc);
|
|
}
|
|
return { type: "other" };
|
|
}
|
|
|
|
function keyOf(page) {
|
|
return page.site + ":" + page.seriesId;
|
|
}
|
|
|
|
function adapterFor(site) {
|
|
return ADAPTERS.find((a) => a.site === site) || null;
|
|
}
|
|
|
|
// Highest chapter the site lists, or null when the markup yields nothing.
|
|
function computeLatestChapter(site, anchors) {
|
|
const a = adapterFor(site);
|
|
return a ? a.latestChapterFromAnchors(anchors) : null;
|
|
}
|
|
|
|
function currentSite() {
|
|
for (const a of ADAPTERS) if (a.matches(window.location)) return a.site;
|
|
return null;
|
|
}
|
|
|
|
// ============================================================
|
|
// API client + local cache
|
|
// ============================================================
|
|
|
|
function loadCache() {
|
|
try {
|
|
const raw = localStorage.getItem(CACHE_KEY);
|
|
return raw ? JSON.parse(raw) : [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveCache(list) {
|
|
try {
|
|
localStorage.setItem(CACHE_KEY, JSON.stringify(list));
|
|
} catch (e) {
|
|
/* quota / private mode — ignore */
|
|
}
|
|
}
|
|
|
|
function loadLastChecked() {
|
|
try {
|
|
const raw = localStorage.getItem(LASTCHECKED_KEY);
|
|
return raw ? JSON.parse(raw) : {};
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveLastChecked(map) {
|
|
try {
|
|
localStorage.setItem(LASTCHECKED_KEY, JSON.stringify(map));
|
|
} catch (e) {
|
|
/* quota / private mode — ignore */
|
|
}
|
|
}
|
|
|
|
function authHeaders(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() {
|
|
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
|
|
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
// 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(body),
|
|
});
|
|
if (!res.ok) throw httpError("PUT /bookmarks", res.status);
|
|
return res.json();
|
|
}
|
|
|
|
async function apiDelete(key) {
|
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
|
method: "DELETE",
|
|
headers: authHeaders(),
|
|
});
|
|
// 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);
|
|
}
|
|
|
|
// ============================================================
|
|
// State
|
|
// ============================================================
|
|
|
|
const state = {
|
|
list: loadCache(), // last-known bookmarks (array)
|
|
byKey: {},
|
|
page: { type: "other" },
|
|
};
|
|
reindex();
|
|
|
|
function reindex() {
|
|
state.byKey = {};
|
|
for (const b of state.list) state.byKey[b.key] = b;
|
|
state.list.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
|
|
}
|
|
|
|
function setList(list) {
|
|
state.list = Array.isArray(list) ? list : [];
|
|
reindex();
|
|
saveCache(state.list);
|
|
}
|
|
|
|
function upsertLocal(bm) {
|
|
const i = state.list.findIndex((b) => b.key === bm.key);
|
|
if (i >= 0) state.list[i] = bm;
|
|
else state.list.push(bm);
|
|
reindex();
|
|
saveCache(state.list);
|
|
}
|
|
|
|
function removeLocal(key) {
|
|
state.list = state.list.filter((b) => b.key !== key);
|
|
reindex();
|
|
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";
|
|
}
|
|
|
|
// ============================================================
|
|
// 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)
|
|
// ============================================================
|
|
|
|
// 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() {
|
|
const p = state.page;
|
|
if (p.type !== "series" && p.type !== "chapter") return;
|
|
const key = keyOf(p);
|
|
const existing = state.byKey[key];
|
|
const bm = {
|
|
key: key,
|
|
site: p.site,
|
|
series_id: p.seriesId,
|
|
title: p.title || (existing && existing.title) || p.seriesId,
|
|
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
|
cover: p.cover || (existing && existing.cover) || "",
|
|
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
|
|
last_chapter_num:
|
|
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
|
|
last_chapter_url: p.chapterUrl || (existing && existing.last_chapter_url) || "",
|
|
updated_at: Date.now(),
|
|
};
|
|
await syncUpsert(bm, "Bookmarked");
|
|
}
|
|
|
|
async function updateToCurrentChapter() {
|
|
cancelDwell(); // manual button + timer-fire both clear any pending dwell
|
|
const p = state.page;
|
|
if (p.type !== "chapter") return;
|
|
const key = keyOf(p);
|
|
const existing = state.byKey[key] || {};
|
|
const bm = Object.assign({}, existing, {
|
|
key: key,
|
|
site: p.site,
|
|
series_id: p.seriesId,
|
|
title: existing.title || p.title || p.seriesId,
|
|
series_url: existing.series_url || p.seriesUrl || "",
|
|
cover: existing.cover || p.cover || "",
|
|
last_chapter: p.chapterLabel || "",
|
|
last_chapter_num: p.chapterNum,
|
|
last_chapter_url: p.chapterUrl || "",
|
|
updated_at: Date.now(),
|
|
});
|
|
await syncUpsert(bm, "Progress updated");
|
|
}
|
|
|
|
// Manual override from the panel input — forces any value.
|
|
async function setChapterManual(key, label, num, url) {
|
|
const existing = state.byKey[key];
|
|
if (!existing) return;
|
|
const bm = Object.assign({}, existing, {
|
|
last_chapter: label,
|
|
last_chapter_num: isNaN(num) ? existing.last_chapter_num : num,
|
|
last_chapter_url: url || existing.last_chapter_url,
|
|
updated_at: Date.now(),
|
|
});
|
|
await syncUpsert(bm, "Progress set");
|
|
}
|
|
|
|
async function syncUpsert(bm, okMsg) {
|
|
upsertLocal(bm); // optimistic
|
|
render();
|
|
if (await pushBookmark(bm.key, false)) toast(okMsg);
|
|
else toast("Saved — will sync when online", true);
|
|
}
|
|
|
|
async function toggleFavorite(key) {
|
|
const existing = state.byKey[key];
|
|
if (!existing) return;
|
|
const bm = Object.assign({}, existing, {
|
|
favorite: !existing.favorite,
|
|
updated_at: Date.now(), // candidate only; the server keeps the stored one
|
|
});
|
|
upsertLocal(bm); // optimistic
|
|
render();
|
|
if (!(await pushBookmark(bm.key, false))) toast("Saved — will sync when online", true);
|
|
}
|
|
|
|
// 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";
|
|
upsertLocal(Object.assign({}, existing, { status: next }));
|
|
render();
|
|
// The only write with an opinion about the bucket, so the only one that
|
|
// sends `status` at all.
|
|
if (await pushBookmark(key, true)) {
|
|
toast(next === "archived" ? "Archived" : "Back in your list");
|
|
} else {
|
|
toast((next === "archived" ? "Archived" : "Restored") + " — will sync 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
|
|
// reading progress moved.
|
|
async function applyLatestChapterIfChanged(existing, latest) {
|
|
if (!existing || !latest) return;
|
|
if (existing.latest_chapter_num === latest.num) return;
|
|
const bm = Object.assign({}, existing, {
|
|
latest_chapter: latest.label,
|
|
latest_chapter_num: latest.num,
|
|
updated_at: Date.now(),
|
|
});
|
|
upsertLocal(bm);
|
|
render();
|
|
// A queued write owns this row; the drain sends latest_chapter
|
|
// with it, carrying the correct bucket.
|
|
if (queueGet(bm.key)) return;
|
|
try {
|
|
const saved = await apiPut(bm.key, bm);
|
|
upsertLocal(saved);
|
|
render();
|
|
} catch (e) {
|
|
/* offline — the local cache still shows it, retried on a later visit */
|
|
}
|
|
}
|
|
|
|
// Standing on a bookmarked series page: its chapter list is already here.
|
|
function maybeCaptureLatestOnSeriesPage() {
|
|
const p = state.page;
|
|
if (p.type !== "series") return;
|
|
const existing = state.byKey[keyOf(p)];
|
|
if (!existing) return;
|
|
applyLatestChapterIfChanged(
|
|
existing,
|
|
computeLatestChapter(p.site, anchorsFromDocument(document))
|
|
);
|
|
}
|
|
|
|
// Everything else is only learned by fetching a series page. Same-origin
|
|
// only: these requests carry the session that gets us past the site's bot
|
|
// checks, which a request to the other site (or from a server) would not.
|
|
// One series per navigation keeps it indistinguishable from browsing.
|
|
async function backgroundRefreshLatest() {
|
|
const site = currentSite();
|
|
if (!site) return;
|
|
|
|
const checked = loadLastChecked();
|
|
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);
|
|
if (due.length === 0) return;
|
|
|
|
for (const bm of due) {
|
|
// Recorded even when the fetch fails, so a broken series is retried on
|
|
// the next throttle window rather than on every single page load.
|
|
checked[bm.key] = Date.now();
|
|
try {
|
|
const res = await fetch(bm.series_url, { credentials: "same-origin" });
|
|
if (!res.ok) continue;
|
|
const html = await res.text();
|
|
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html));
|
|
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
|
|
} catch (e) {
|
|
/* offline or blocked — try again after the throttle window */
|
|
}
|
|
}
|
|
saveLastChecked(checked);
|
|
}
|
|
|
|
async function removeBookmark(key) {
|
|
removeLocal(key); // optimistic
|
|
render();
|
|
if (await pushDelete(key)) toast("Removed");
|
|
else toast("Removed — will sync when online", true);
|
|
}
|
|
|
|
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
|
|
// against a misclick on "latest chapter"). Arm a 25s timer instead; the countdown
|
|
// ring around the FAB shows it running. The manual "Update to X" button still fires
|
|
// immediately. Timer is keyed to the chapter, not the URL, so turning pages within
|
|
// the same chapter (Demonic /chapter/N/<page>) keeps it counting rather than resetting.
|
|
const DWELL_MS = 25000;
|
|
let dwell = null; // { id, timerId }
|
|
|
|
function chapterIdentity(p) {
|
|
return p && p.type === "chapter" ? p.site + ":" + p.seriesId + "@" + p.chapterNum : null;
|
|
}
|
|
|
|
function armDwell(id) {
|
|
dwell = {
|
|
id,
|
|
timerId: setTimeout(() => {
|
|
dwell = null;
|
|
stopRing();
|
|
updateToCurrentChapter();
|
|
}, DWELL_MS),
|
|
};
|
|
startRing(DWELL_MS);
|
|
}
|
|
|
|
function cancelDwell() {
|
|
if (dwell) clearTimeout(dwell.timerId);
|
|
dwell = null;
|
|
stopRing();
|
|
}
|
|
|
|
// Auto-record on opening a chapter of a bookmarked series (after the dwell delay).
|
|
function maybeAutoUpdate() {
|
|
const p = state.page;
|
|
const id = chapterIdentity(p);
|
|
if (dwell && id === dwell.id) return; // same chapter, page turn — keep timer running
|
|
cancelDwell(); // navigated elsewhere — drop any stale dwell
|
|
if (p.type !== "chapter") return;
|
|
const existing = state.byKey[keyOf(p)];
|
|
if (!existing) return; // only track series the user bookmarked
|
|
|
|
const cur = p.chapterNum;
|
|
const prev = existing.last_chapter_num;
|
|
// Advance when current >= stored, or when either is unparseable (set current).
|
|
const advance = cur == null || prev == null || cur >= prev;
|
|
if (!advance) return;
|
|
if (existing.last_chapter_url === p.chapterUrl && existing.last_chapter === p.chapterLabel) {
|
|
return; // already recorded
|
|
}
|
|
armDwell(id);
|
|
}
|
|
|
|
// ============================================================
|
|
// UI — Shadow DOM (isolated from site CSS)
|
|
// ============================================================
|
|
|
|
let root, panelOpen = false;
|
|
let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design
|
|
|
|
function buildUI() {
|
|
const host = document.createElement("div");
|
|
host.id = "mangabm-host";
|
|
host.style.cssText = "all: initial;";
|
|
(document.body || document.documentElement).appendChild(host);
|
|
root = host.attachShadow({ mode: "open" });
|
|
|
|
const style = document.createElement("style");
|
|
style.textContent = CSS;
|
|
root.appendChild(style);
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.id = "wrap";
|
|
wrap.innerHTML = TEMPLATE;
|
|
root.appendChild(wrap);
|
|
|
|
const fab = root.getElementById("fab");
|
|
fab.addEventListener("click", () => {
|
|
if (fab.dataset.dragged) {
|
|
delete fab.dataset.dragged; // this click is the tail of a drag — swallow it
|
|
return;
|
|
}
|
|
togglePanel();
|
|
});
|
|
root.getElementById("backdrop").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"]]) {
|
|
root.getElementById(id).addEventListener("click", () => {
|
|
activeTab = tab;
|
|
render();
|
|
});
|
|
}
|
|
|
|
placeFab(fab);
|
|
makeDraggable(fab);
|
|
window.addEventListener("resize", () => placeFab(fab)); // re-clamp on rotation
|
|
}
|
|
|
|
// ============================================================
|
|
// FAB placement + dragging (snaps to nearest left/right edge)
|
|
// ============================================================
|
|
|
|
const FAB_KEY = "mangabm:fabpos";
|
|
const FAB_MARGIN = 12;
|
|
|
|
function clamp(v, lo, hi) {
|
|
return Math.max(lo, Math.min(hi, v));
|
|
}
|
|
|
|
function applyFabPos(fab, side, y) {
|
|
fab.style.top = y + "px";
|
|
fab.style.bottom = "auto";
|
|
if (side === "left") {
|
|
fab.style.left = FAB_MARGIN + "px";
|
|
fab.style.right = "auto";
|
|
} else {
|
|
fab.style.right = FAB_MARGIN + "px";
|
|
fab.style.left = "auto";
|
|
}
|
|
}
|
|
|
|
function loadFabPos() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(FAB_KEY));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveFabPos(side, yRatio) {
|
|
try {
|
|
localStorage.setItem(FAB_KEY, JSON.stringify({ side, yRatio }));
|
|
} catch (e) {
|
|
/* quota / private mode — ignore */
|
|
}
|
|
}
|
|
|
|
function placeFab(fab) {
|
|
const s = fab.offsetWidth || 52;
|
|
const p = loadFabPos();
|
|
const maxY = window.innerHeight - s - FAB_MARGIN;
|
|
const side = p && p.side === "left" ? "left" : "right";
|
|
const y = p ? clamp(p.yRatio * window.innerHeight, FAB_MARGIN, maxY) : maxY - 8;
|
|
applyFabPos(fab, side, y);
|
|
}
|
|
|
|
function makeDraggable(fab) {
|
|
let down = false, moved = false, sx = 0, sy = 0, offX = 0, offY = 0;
|
|
fab.addEventListener("pointerdown", (e) => {
|
|
down = true;
|
|
moved = false;
|
|
sx = e.clientX;
|
|
sy = e.clientY;
|
|
const r = fab.getBoundingClientRect();
|
|
offX = e.clientX - r.left;
|
|
offY = e.clientY - r.top;
|
|
fab.setPointerCapture(e.pointerId);
|
|
fab.style.transition = "none";
|
|
});
|
|
fab.addEventListener("pointermove", (e) => {
|
|
if (!down) return;
|
|
if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true;
|
|
if (!moved) return;
|
|
e.preventDefault();
|
|
const s = fab.offsetWidth;
|
|
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - s) + "px";
|
|
fab.style.right = "auto";
|
|
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - s) + "px";
|
|
fab.style.bottom = "auto";
|
|
});
|
|
fab.addEventListener("pointerup", (e) => {
|
|
if (!down) return;
|
|
down = false;
|
|
fab.style.transition = "";
|
|
if (!moved) return; // tap — let the click handler toggle the panel
|
|
fab.dataset.dragged = "1"; // suppress the click that trails this drag
|
|
const s = fab.offsetWidth;
|
|
const r = fab.getBoundingClientRect();
|
|
const side = r.left + s / 2 < window.innerWidth / 2 ? "left" : "right";
|
|
const y = clamp(r.top, FAB_MARGIN, window.innerHeight - s - FAB_MARGIN);
|
|
fab.style.transition = "left .2s ease, right .2s ease, top .2s ease";
|
|
applyFabPos(fab, side, y);
|
|
saveFabPos(side, y / window.innerHeight);
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Countdown ring around the FAB (dwell-timer progress)
|
|
// ============================================================
|
|
|
|
const RING_C = 144.5; // circumference: 2π·23
|
|
|
|
function startRing(ms) {
|
|
if (!root) return;
|
|
const ring = root.getElementById("ring");
|
|
const prog = ring.firstElementChild;
|
|
ring.classList.add("active");
|
|
prog.style.transition = "none";
|
|
prog.style.strokeDashoffset = RING_C; // empty
|
|
prog.getBoundingClientRect(); // reflow so the next assignment animates
|
|
prog.style.transition = "stroke-dashoffset " + ms + "ms linear";
|
|
prog.style.strokeDashoffset = "0"; // fills over ms
|
|
}
|
|
|
|
function stopRing() {
|
|
if (!root) return;
|
|
const ring = root.getElementById("ring");
|
|
if (!ring) return;
|
|
const prog = ring.firstElementChild;
|
|
ring.classList.remove("active");
|
|
prog.style.transition = "none";
|
|
prog.style.strokeDashoffset = RING_C;
|
|
}
|
|
|
|
function togglePanel() {
|
|
panelOpen = !panelOpen;
|
|
render();
|
|
if (panelOpen) refresh(); // pull latest from server on open
|
|
}
|
|
|
|
function el(tag, props, children) {
|
|
const e = document.createElement(tag);
|
|
if (props) for (const k in props) {
|
|
if (k === "text") e.textContent = props[k];
|
|
else if (k === "html") e.innerHTML = props[k];
|
|
else if (k.slice(0, 2) === "on") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
|
|
else e.setAttribute(k, props[k]);
|
|
}
|
|
if (children) for (const c of children) if (c) e.appendChild(c);
|
|
return e;
|
|
}
|
|
|
|
function render() {
|
|
if (!root) return;
|
|
root.getElementById("panel").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.
|
|
const ctx = root.getElementById("context");
|
|
ctx.innerHTML = "";
|
|
const p = state.page;
|
|
if (p.type === "series" || p.type === "chapter") {
|
|
const key = keyOf(p);
|
|
const existing = state.byKey[key];
|
|
ctx.appendChild(el("div", { class: "ctx-title", text: p.title || p.seriesId }));
|
|
if (!existing) {
|
|
ctx.appendChild(el("button", { class: "btn primary", text: "+ Bookmark this", onclick: () => bookmarkCurrent() }));
|
|
} else {
|
|
const cur = existing.last_chapter || "—";
|
|
ctx.appendChild(el("div", { class: "ctx-sub", text: "Tracked · last read: " + cur }));
|
|
if (p.type === "chapter" && p.chapterLabel && p.chapterLabel !== existing.last_chapter) {
|
|
ctx.appendChild(el("button", { class: "btn primary", text: "Update to " + p.chapterLabel, onclick: () => updateToCurrentChapter() }));
|
|
}
|
|
}
|
|
} else {
|
|
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." }));
|
|
}
|
|
|
|
// 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 = 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) {
|
|
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));
|
|
}
|
|
}
|
|
|
|
function renderItem(b) {
|
|
const cont = b.last_chapter_url || b.series_url || "#";
|
|
// Only worth naming the latest chapter while it is ahead of the read one.
|
|
const behind =
|
|
b.latest_chapter &&
|
|
b.latest_chapter_num != null &&
|
|
(b.last_chapter_num == null || b.latest_chapter_num > b.last_chapter_num);
|
|
const sub = behind
|
|
? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter
|
|
: (b.last_chapter || "—") + " · " + b.site;
|
|
const row = el("div", { class: "item" }, [
|
|
b.cover
|
|
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
|
: el("div", { class: "cover ph" }),
|
|
el("div", { class: "meta" }, [
|
|
el("div", { class: "t", text: b.title || b.series_id }),
|
|
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
|
el("div", { class: "actions" }, [
|
|
el("button", {
|
|
class: "btn small star" + (b.favorite ? " on" : ""),
|
|
text: b.favorite ? "★" : "☆",
|
|
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) }),
|
|
]),
|
|
]),
|
|
]);
|
|
return row;
|
|
}
|
|
|
|
function editChapter(b) {
|
|
const raw = prompt("Set last-read chapter for \"" + (b.title || b.series_id) + "\":", b.last_chapter || "");
|
|
if (raw == null) return;
|
|
const label = raw.trim();
|
|
const num = parseFloat((label.match(/[\d.]+/) || [])[0]);
|
|
setChapterManual(b.key, label, num, b.last_chapter_url);
|
|
}
|
|
|
|
// Toast inside the shadow root.
|
|
function toast(msg, isErr) {
|
|
if (!root) return;
|
|
const t = el("div", { class: "toast" + (isErr ? " err" : ""), text: msg });
|
|
root.getElementById("toasts").appendChild(t);
|
|
setTimeout(() => t.classList.add("show"), 10);
|
|
setTimeout(() => {
|
|
t.classList.remove("show");
|
|
setTimeout(() => t.remove(), 300);
|
|
}, 2600);
|
|
}
|
|
|
|
// ============================================================
|
|
// Refresh + navigation
|
|
// ============================================================
|
|
|
|
async function refresh() {
|
|
await drain(); // push what we owe before adopting the server's view of it
|
|
try {
|
|
const list = await apiGet();
|
|
setList(overlayPending(list));
|
|
render();
|
|
} catch (e) {
|
|
render(); // fall back to cache
|
|
}
|
|
}
|
|
|
|
let lastUrl = location.href;
|
|
function onNavigate() {
|
|
state.page = detect();
|
|
render();
|
|
maybeAutoUpdate();
|
|
maybeCaptureLatestOnSeriesPage();
|
|
// Asura is client-routed, so full loads are rare — checking here too is
|
|
// what keeps its bookmarks current. The throttle still caps the rate.
|
|
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,
|
|
// so it works whether the site is client-routed (Asura) or reloads (Demonic).
|
|
function installNavWatcher() {
|
|
const fire = () => {
|
|
if (location.href !== lastUrl) {
|
|
lastUrl = location.href;
|
|
// Meta tags update slightly after the URL on client routes.
|
|
setTimeout(onNavigate, 300);
|
|
}
|
|
};
|
|
const wrap = (name) => {
|
|
const orig = history[name];
|
|
history[name] = function () {
|
|
const r = orig.apply(this, arguments);
|
|
fire();
|
|
return r;
|
|
};
|
|
};
|
|
wrap("pushState");
|
|
wrap("replaceState");
|
|
window.addEventListener("popstate", fire);
|
|
setInterval(fire, 1500); // catch routes that bypass history
|
|
}
|
|
|
|
// ============================================================
|
|
// Boot
|
|
// ============================================================
|
|
|
|
function init() {
|
|
buildUI();
|
|
state.page = detect();
|
|
render();
|
|
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
|
|
// know which series are bookmarked and how fresh they are.
|
|
refresh().then(() => {
|
|
maybeAutoUpdate();
|
|
maybeCaptureLatestOnSeriesPage();
|
|
backgroundRefreshLatest();
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Markup + styles (kept at bottom for readability)
|
|
// ============================================================
|
|
|
|
const TEMPLATE = `
|
|
<button id="fab" aria-label="Manga bookmarks">
|
|
<svg id="ring" viewBox="0 0 52 52" aria-hidden="true"><circle class="prog" cx="26" cy="26" r="23"></circle></svg>
|
|
<span class="fab-icon">📑</span>
|
|
</button>
|
|
<div id="backdrop"></div>
|
|
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
|
<header>
|
|
<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>
|
|
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
|
|
</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>
|
|
<div id="toasts"></div>
|
|
`;
|
|
|
|
const CSS = `
|
|
:host { all: initial; }
|
|
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
|
#fab {
|
|
position: fixed;
|
|
width: 52px; height: 52px; border-radius: 50%; border: none;
|
|
background: #6d28d9; color: #fff; font-size: 22px;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
|
touch-action: none; user-select: none;
|
|
display: flex; align-items: center; justify-content: center; padding: 0;
|
|
}
|
|
#fab:active { transform: scale(.94); }
|
|
#ring {
|
|
position: absolute; inset: 0; transform: rotate(-90deg);
|
|
opacity: 0; transition: opacity .15s; pointer-events: none;
|
|
}
|
|
#ring.active { opacity: 1; }
|
|
#ring .prog {
|
|
fill: none; stroke: #c4b5fd; stroke-width: 3; stroke-linecap: round;
|
|
stroke-dasharray: 144.5; stroke-dashoffset: 144.5;
|
|
}
|
|
.fab-icon { position: relative; z-index: 1; line-height: 1; }
|
|
#backdrop {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
|
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
|
|
}
|
|
#backdrop.open { opacity: 1; pointer-events: auto; }
|
|
#panel {
|
|
position: fixed; top: 0; right: 0; height: 100%;
|
|
width: min(400px, 92vw); background: #1b1b22; color: #eee;
|
|
transform: translateX(105%); transition: transform .25s ease;
|
|
z-index: 2147483647; display: flex; flex-direction: column;
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
box-shadow: -4px 0 20px rgba(0,0,0,.5);
|
|
}
|
|
#panel.open { transform: translateX(0); }
|
|
header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
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; }
|
|
.chip.pending { background: #4c1d95; border: none; cursor: pointer; }
|
|
.chip.pending[hidden] { display: none; }
|
|
#context {
|
|
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
}
|
|
.ctx-title { font-weight: 600; font-size: 15px; }
|
|
.ctx-sub { color: #9a9aa5; font-size: 13px; }
|
|
#tabs { display: flex; gap: 14px; padding: 10px 16px 0; border-bottom: 1px solid #33333d; }
|
|
.tab {
|
|
background: none; border: none; color: #9a9aa5; cursor: pointer;
|
|
font-size: 13px; font-weight: 600; padding: 4px 0 9px;
|
|
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
|
}
|
|
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
|
|
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
|
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
|
.item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; }
|
|
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
|
|
.cover.ph { display: block; }
|
|
.meta { min-width: 0; flex: 1; }
|
|
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; }
|
|
.c.behind { color: #c4b5fd; }
|
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.btn {
|
|
background: #33333d; color: #eee; border: none; border-radius: 6px;
|
|
padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block;
|
|
}
|
|
.btn:active { opacity: .8; }
|
|
.btn.primary { background: #6d28d9; color: #fff; }
|
|
.btn.small { padding: 6px 10px; font-size: 12px; }
|
|
.btn.danger { background: #7f1d1d; }
|
|
.btn.star { line-height: 1; }
|
|
.btn.star.on { background: #6d28d9; color: #fff; }
|
|
#toasts {
|
|
position: fixed; left: 50%; transform: translateX(-50%);
|
|
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
|
z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center;
|
|
}
|
|
.toast {
|
|
background: #2f2f3a; color: #fff; padding: 10px 16px; border-radius: 8px;
|
|
font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .3s;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,.4); max-width: 80vw;
|
|
}
|
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
.toast.err { background: #7f1d1d; }
|
|
`;
|
|
|
|
// ============================================================
|
|
// Boot — MUST run last: init() -> buildUI() reads TEMPLATE/CSS,
|
|
// which are const (TDZ) and only initialized above. At
|
|
// @run-at document-idle document.body already exists, so init()
|
|
// fires synchronously here, after the consts are set.
|
|
// ============================================================
|
|
if (document.body) init();
|
|
else document.addEventListener("DOMContentLoaded", init);
|
|
})();
|