a4da2e2f00
The lightnovelworld chapter branch no longer derives the Series address by string-manipulating the chapter path: on ~7% of novels the Chapter Slug diverges from the Series slug and the derived address 404s on every Poll. The identity now comes from the page's own a[aria-label='All Chapter'] pointer (breadcrumb's second crumb as fallback), validated as a lightnovelworld.net /novel/<slug>/ address; no pointer resolves to type: other. The Chapter Slug rides on the detected page object only (chapterSlug; null on a series page) and is written to no store, for the stale-row repair in #90. Harness: document.querySelector now answers attribute selectors with an element exposing getAttribute (attrEls table, cleared by reset()). AGENTS.md records that the Series address is discovered, not derived.
1631 lines
62 KiB
JavaScript
1631 lines
62 KiB
JavaScript
// ==UserScript==
|
|
// @name Novel Bookmark Sync
|
|
// @namespace bmgr
|
|
// @version 1.0.0
|
|
// @description Track read progress on NovelFull & LightNovelWorld and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
|
// @author you
|
|
// @downloadURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/novel-bookmark.user.js
|
|
// @updateURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/novel-bookmark.user.js
|
|
// @match https://novelfull.com/*
|
|
// @match https://lightnovelworld.net/*
|
|
// @run-at document-idle
|
|
// @noframes
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
// ============================================================
|
|
// CONFIG — fill these in before installing.
|
|
// ============================================================
|
|
const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
|
const API_TOKEN = "__API_TOKEN__"; // substituted by the backend at serve time (issue #24)
|
|
const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips
|
|
|
|
// This script owns the novel library; the manga script is a separate install
|
|
// with its own prefix, so the two never share a cache, a queue or a panel.
|
|
const STORE_PREFIX = "bmgr:novel:";
|
|
const CACHE_KEY = STORE_PREFIX + "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 = STORE_PREFIX + "lastchecked";
|
|
const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000;
|
|
const LATEST_CHECK_BATCH = 1; // series fetched per navigation // series fetched per navigation
|
|
|
|
// Which library this script's rows belong to. The manga script is a separate
|
|
// install that declares "manga"; the backend keeps whichever it is told.
|
|
const LIBRARY = "novel";
|
|
|
|
// ============================================================
|
|
// Site adapters
|
|
//
|
|
// Page type + IDs come from URL regex (most stable); the title comes from
|
|
// og: meta tags or the page's own heading. Covers are never read here: the
|
|
// backend acquires and serves them itself (ADR-0007).
|
|
// ============================================================
|
|
|
|
// 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;
|
|
}
|
|
|
|
function escapeRe(s) {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
// Neither site lists chapters in a dependable order — novelfull puts a
|
|
// newest-first widget above an oldest-first page, lightnovelworld lists all
|
|
// of them ascending — so the newest is the maximum, never the first or last.
|
|
function maxChapter(anchors, re) {
|
|
let best = null;
|
|
for (const a of anchors) {
|
|
const m = (a.href || "").match(re);
|
|
if (!m) continue;
|
|
const num = parseFloat(m[1]);
|
|
if (isNaN(num)) continue;
|
|
if (!best || num > best.num) best = { num: num, label: "Chapter " + m[1] };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
const novelfull = {
|
|
site: "novelfull",
|
|
matches: (loc) => /(^|\.)novelfull\.com$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
// /<slug>/chapter-<n>[-<title-slug>].html
|
|
let m = path.match(/^\/([^/]+)\/chapter-([0-9]+(?:\.[0-9]+)?)/);
|
|
if (m) {
|
|
const num = parseFloat(m[2]);
|
|
const back = document.querySelector("a.truyen-title");
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
// h3.title on a chapter page is the *chapter's* title; the breadcrumb
|
|
// link back to the series page carries the series name.
|
|
title: back ? (back.textContent || "").trim() : "",
|
|
seriesUrl: loc.origin + "/" + m[1] + ".html",
|
|
chapterLabel: "Chapter " + m[2],
|
|
chapterNum: isNaN(num) ? null : num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// /<slug>.html
|
|
m = path.match(/^\/([^/]+)\.html$/);
|
|
if (m) {
|
|
const h3 = document.querySelector("h3.title");
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
title: h3 ? (h3.textContent || "").trim() : "",
|
|
seriesUrl: loc.origin + "/" + m[1] + ".html",
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
},
|
|
latestChapterFromAnchors(anchors, seriesId) {
|
|
return maxChapter(anchors, new RegExp("/" + escapeRe(seriesId) + "/chapter-([0-9.]+)"));
|
|
},
|
|
};
|
|
|
|
// A chapter page's pointer is its own link back to its Series. The href is
|
|
// page markup, so validate before trusting: the host must be this Site's (a
|
|
// leading subdomain is allowed, as in `matches`) and the path the
|
|
// /novel/<slug>/ Series shape. Anything else is not a pointer.
|
|
function seriesIdFromLnwPointer(href) {
|
|
if (!href) return null;
|
|
let u;
|
|
try {
|
|
u = new URL(href);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
if (!/(^|\.)lightnovelworld\.net$/.test(u.hostname)) return null;
|
|
const m = u.pathname.match(/^\/novel\/([^/]+)\/?$/);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
const lightnovelworld = {
|
|
site: "lightnovelworld",
|
|
matches: (loc) => /(^|\.)lightnovelworld\.net$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
// /<slug>-chapter-<n>/ — flat, at the site root, not under /novel/. The
|
|
// greedy group takes the LAST "-chapter-", so a slug containing those
|
|
// words still resolves to the right series.
|
|
let m = path.match(/^\/(.+)-chapter-([0-9]+(?:\.[0-9]+)?)\/?$/);
|
|
if (m) {
|
|
// The address is not the identity on this Site: the slug in the path
|
|
// is a Chapter Slug, which can differ from the Series slug and is
|
|
// never computable from it. The Series address is read from the
|
|
// page's own pointer — a silent fallback to derivation is the defect
|
|
// this replaced, not a safety net.
|
|
const chapterSlug = m[1];
|
|
const pointer = document.querySelector("a[aria-label='All Chapter']");
|
|
let seriesId = pointer ? seriesIdFromLnwPointer(pointer.getAttribute("href")) : null;
|
|
if (!seriesId) {
|
|
// Fallback: the microdata breadcrumb's second crumb is the Series.
|
|
// Scoped to the BreadcrumbList because itemprop="item" is not
|
|
// unique to it (the header nav uses microdata too).
|
|
const crumb = document.querySelector(
|
|
'[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]'
|
|
);
|
|
seriesId = crumb ? seriesIdFromLnwPointer(crumb.getAttribute("href")) : null;
|
|
}
|
|
// Neither pointer present, nor either pointing at a /novel/<slug>/
|
|
// address on this host: not a page the script understands, so no
|
|
// Bookmark under an invented identity.
|
|
if (!seriesId) return { type: "other" };
|
|
const num = parseFloat(m[2]);
|
|
const h1 = document.querySelector("h1.entry-title");
|
|
const heading = h1 ? h1.textContent || "" : "";
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: seriesId,
|
|
chapterSlug: chapterSlug,
|
|
// The heading is "<Series> Chapter <n>"; drop the suffix.
|
|
title: heading.replace(/\s*Chapter\s+[0-9.]+\s*$/i, "").trim(),
|
|
seriesUrl: "https://lightnovelworld.net/novel/" + seriesId + "/",
|
|
chapterLabel: "Chapter " + m[2],
|
|
chapterNum: isNaN(num) ? null : num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// /novel/<slug>/
|
|
m = path.match(/^\/novel\/([^/]+)\/?$/);
|
|
if (m) {
|
|
const h1 = document.querySelector("h1.entry-title");
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
chapterSlug: null,
|
|
title: h1 ? (h1.textContent || "").trim() : "",
|
|
seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/",
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
},
|
|
latestChapterFromAnchors(anchors, seriesId) {
|
|
return maxChapter(
|
|
anchors,
|
|
new RegExp("lightnovelworld\\.net/" + escapeRe(seriesId) + "-chapter-([0-9.]+)/")
|
|
);
|
|
},
|
|
};
|
|
|
|
const ADAPTERS = [novelfull, lightnovelworld];
|
|
|
|
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.
|
|
// seriesId is only consulted by adapters whose pages carry other series'
|
|
// chapter links; the rest ignore it.
|
|
function computeLatestChapter(site, anchors, seriesId) {
|
|
const a = adapterFor(site);
|
|
return a ? a.latestChapterFromAnchors(anchors, seriesId) : 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;
|
|
// Covers belong to the backend, which acquires and serves them itself
|
|
// (ADR-0007) and ignores an incoming one; a third-party address must never
|
|
// go back on the wire.
|
|
delete body.cover;
|
|
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) {
|
|
// GET /bookmarks answers with every library; this panel owns one of them,
|
|
// and the cache must not hold rows it can never show.
|
|
state.list = (Array.isArray(list) ? list : []).filter((b) => kindOf(b) === LIBRARY);
|
|
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";
|
|
}
|
|
|
|
// A list cached by an older version has no kind field, and a row the server
|
|
// defaulted has "manga" — both mean the same thing here.
|
|
function kindOf(b) {
|
|
return b.kind || "manga";
|
|
}
|
|
|
|
// ============================================================
|
|
// 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 under CACHE_KEY. 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 = STORE_PREFIX + "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,
|
|
kind: LIBRARY,
|
|
series_id: p.seriesId,
|
|
title: p.title || (existing && existing.title) || p.seriesId,
|
|
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
|
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,
|
|
kind: LIBRARY,
|
|
series_id: p.seriesId,
|
|
title: existing.title || p.title || p.seriesId,
|
|
series_url: existing.series_url || p.seriesUrl || "",
|
|
last_chapter: p.chapterLabel || existing.last_chapter || "",
|
|
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
|
|
last_chapter_url: p.chapterUrl || "",
|
|
updated_at: Date.now(),
|
|
});
|
|
await syncUpsert(bm, "Progress updated");
|
|
}
|
|
|
|
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), p.seriesId)
|
|
);
|
|
}
|
|
|
|
// Everything else is only learned by fetching a series page — or, where the
|
|
// site offers one, its JSON API. 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. A few 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;
|
|
|
|
const adapter = adapterFor(site);
|
|
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), bm.series_id);
|
|
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 tab fills
|
|
// bottom-up to show it counting down. 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;
|
|
// True while refresh() is waiting on the backend. Only changes what the list
|
|
// draws when there is nothing cached to draw instead.
|
|
let loading = 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 = STORE_PREFIX + "fabpos";
|
|
const FAB_MARGIN = 12; // vertical breathing room at the top and bottom
|
|
const FAB_EDGE = 0; // horizontal: an edge tab sits flush against the side
|
|
const ARM_MS = 400; // hold this long on the invisible strip to arm a drag
|
|
|
|
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";
|
|
fab.classList.toggle("left", side === "left");
|
|
if (side === "left") {
|
|
fab.style.left = FAB_EDGE + "px";
|
|
fab.style.right = "auto";
|
|
} else {
|
|
fab.style.right = FAB_EDGE + "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 h = fab.offsetHeight || 44;
|
|
const p = loadFabPos();
|
|
const maxY = window.innerHeight - h - 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);
|
|
}
|
|
|
|
// A gesture that starts on the visible sliver drags immediately, as it always
|
|
// has. A gesture that starts on the invisible #hit strip scrolls the page
|
|
// instead — unless it is held for ARM_MS first, which arms a reposition drag.
|
|
// ponytail: the scroll is hand-rolled (no momentum/fling) and assumes the
|
|
// document is the scroller. That is the price of touch-action:none, which we
|
|
// need so a hold can claim pixels a swipe would otherwise scroll. If the
|
|
// stiffness annoys, give #hit touch-action:pan-y and accept that vertical
|
|
// repositioning must start on the 7px sliver.
|
|
function makeDraggable(fab) {
|
|
let down = false, moved = false, armed = false, scrolling = false, fromHit = false;
|
|
let sx = 0, sy = 0, offX = 0, offY = 0, lastY = 0, armTimer = 0;
|
|
|
|
function reset() {
|
|
down = moved = armed = scrolling = fromHit = false;
|
|
clearTimeout(armTimer);
|
|
fab.classList.remove("armed");
|
|
fab.style.transition = "";
|
|
}
|
|
|
|
function scrollPage(e) {
|
|
e.preventDefault();
|
|
window.scrollBy(0, lastY - e.clientY);
|
|
lastY = e.clientY;
|
|
}
|
|
|
|
fab.addEventListener("pointerdown", (e) => {
|
|
delete fab.dataset.dragged; // stale flag would swallow this gesture's click
|
|
down = true;
|
|
moved = false;
|
|
armed = false;
|
|
scrolling = false;
|
|
fromHit = e.target.id === "hit";
|
|
sx = e.clientX;
|
|
sy = e.clientY;
|
|
lastY = e.clientY;
|
|
const r = fab.getBoundingClientRect();
|
|
offX = e.clientX - r.left;
|
|
offY = e.clientY - r.top;
|
|
fab.setPointerCapture(e.pointerId);
|
|
fab.style.transition = "none";
|
|
clearTimeout(armTimer);
|
|
armTimer = setTimeout(() => {
|
|
armed = true;
|
|
fab.classList.add("armed"); // visible confirmation the hold took
|
|
}, ARM_MS);
|
|
});
|
|
|
|
fab.addEventListener("pointermove", (e) => {
|
|
if (!down) return;
|
|
if (scrolling) {
|
|
scrollPage(e);
|
|
return;
|
|
}
|
|
if (!moved) {
|
|
if (Math.abs(e.clientX - sx) <= 6 && Math.abs(e.clientY - sy) <= 6) return;
|
|
clearTimeout(armTimer);
|
|
if (fromHit && !armed) {
|
|
scrolling = true;
|
|
scrollPage(e);
|
|
return;
|
|
}
|
|
moved = true;
|
|
}
|
|
e.preventDefault();
|
|
const w = fab.offsetWidth, h = fab.offsetHeight;
|
|
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - w) + "px";
|
|
fab.style.right = "auto";
|
|
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px";
|
|
fab.style.bottom = "auto";
|
|
});
|
|
|
|
function snapToEdge() {
|
|
const w = fab.offsetWidth, h = fab.offsetHeight;
|
|
const r = fab.getBoundingClientRect();
|
|
const side = r.left + w / 2 < window.innerWidth / 2 ? "left" : "right";
|
|
const y = clamp(r.top, FAB_MARGIN, window.innerHeight - h - FAB_MARGIN);
|
|
fab.style.transition = "left .2s ease, right .2s ease, top .2s ease";
|
|
applyFabPos(fab, side, y);
|
|
saveFabPos(side, y / window.innerHeight);
|
|
}
|
|
|
|
fab.addEventListener("pointerup", (e) => {
|
|
if (!down) return;
|
|
const didDrag = moved, didScroll = scrolling;
|
|
reset();
|
|
if (didScroll) {
|
|
fab.dataset.dragged = "1"; // suppress any click trailing the scroll
|
|
return;
|
|
}
|
|
if (!didDrag) return; // tap — let the click handler toggle the panel
|
|
fab.dataset.dragged = "1"; // suppress the click that trails this drag
|
|
snapToEdge();
|
|
});
|
|
|
|
fab.addEventListener("pointercancel", () => {
|
|
if (!down) return;
|
|
const didDrag = moved;
|
|
reset();
|
|
if (didDrag) snapToEdge(); // OS claimed the gesture mid-drag — don't strand the FAB
|
|
});
|
|
|
|
// touch-action: none stops panning and zooming but NOT Chromium's long-press
|
|
// gesture, which fires at ~500ms — just after ARM_MS — raises the context
|
|
// menu, and cancels the pointer stream. The hold armed the drag and the OS
|
|
// stole it back a frame later. Suppressing the menu keeps the pointer alive.
|
|
fab.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
}
|
|
|
|
// ============================================================
|
|
// Dwell-timer progress: the tab fills bottom-up. A 7px sliver has no room
|
|
// for the ring this replaced.
|
|
// ============================================================
|
|
|
|
function startRing(ms) {
|
|
if (!root) return;
|
|
const fill = root.getElementById("fill");
|
|
fill.style.transition = "none";
|
|
fill.style.height = "0";
|
|
fill.getBoundingClientRect(); // reflow so the next assignment animates
|
|
fill.style.transition = "height " + ms + "ms linear";
|
|
fill.style.height = "100%";
|
|
}
|
|
|
|
function stopRing() {
|
|
if (!root) return;
|
|
const fill = root.getElementById("fill");
|
|
if (!fill) return;
|
|
fill.style.transition = "none";
|
|
fill.style.height = "0";
|
|
}
|
|
|
|
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 && loading) {
|
|
// Nothing cached and the fetch is still out — without this the panel
|
|
// looks frozen on the first open after a cold start.
|
|
listEl.appendChild(el("div", { class: "empty" }, [el("div", { class: "spinner" })]));
|
|
} else 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;
|
|
// Cover and title are the only continue links. The subtitle and the action
|
|
// row live in the same text column but are not navigation targets — a thumb
|
|
// that misses ★ must not land on one, and Remove must never be one.
|
|
// hot = a chapter is out that has not been read (crimson title, ember rule);
|
|
// dim = archived (italic grey). Same two states the web UI paints.
|
|
const heat = (behind ? " hot" : "") + (statusOf(b) === "archived" ? " dim" : "");
|
|
return el("div", { class: "item" + heat }, [
|
|
el("a", { class: "go", href: cont }, [
|
|
b.cover
|
|
? el("img", {
|
|
class: "cover",
|
|
src: b.cover,
|
|
loading: "lazy",
|
|
alt: "",
|
|
// A Cover that will not load shows the designed placeholder
|
|
// rather than the browser's broken-image glyph (#47).
|
|
onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" })),
|
|
})
|
|
: el("div", { class: "cover ph" }),
|
|
]),
|
|
el("div", { class: "meta" }, [
|
|
el("a", { class: "go-t t", href: cont, 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("button", {
|
|
class: "btn small danger",
|
|
text: "Remove",
|
|
onclick: () => confirmRemove(b),
|
|
}),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// Removal syncs to every device, so it asks first. confirm() is the page's
|
|
// own dialog, not a shadow-root one — modal, and the site cannot style it.
|
|
function confirmRemove(b) {
|
|
if (confirm('Remove "' + (b.title || b.series_id) + '" from your bookmarks?')) {
|
|
removeBookmark(b.key);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
loading = true;
|
|
render();
|
|
try {
|
|
const list = await apiGet();
|
|
setList(overlayPending(list));
|
|
} catch (e) {
|
|
// Offline or backend down: keep whatever the cache holds.
|
|
} finally {
|
|
loading = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ============================================================
|
|
// Two-finger long-press — a way in that does not depend on finding a 7px tab
|
|
//
|
|
// Deliberately two fingers, not one: Chromium claims the one-finger long
|
|
// press for image context menus, text selection, and link previews, and on a
|
|
// manga reader the whole viewport is images. It assigns no two-finger long
|
|
// press, so these listeners stay passive and never preventDefault.
|
|
// ============================================================
|
|
|
|
const LONGPRESS_MS = 500;
|
|
const LONGPRESS_SLOP = 15; // px either finger may drift before it counts as a drag
|
|
|
|
function installLongPress() {
|
|
let timer = null;
|
|
let start = null; // [{x, y}, {x, y}] — where each finger landed
|
|
|
|
const cancel = () => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = null;
|
|
start = null;
|
|
};
|
|
|
|
const opts = { passive: true, capture: true };
|
|
|
|
// Fires once per finger, so the first touch cancels and the second arms.
|
|
// A third finger cancels again.
|
|
document.addEventListener(
|
|
"touchstart",
|
|
(e) => {
|
|
cancel();
|
|
if (e.touches.length !== 2) return;
|
|
start = [
|
|
{ x: e.touches[0].clientX, y: e.touches[0].clientY },
|
|
{ x: e.touches[1].clientX, y: e.touches[1].clientY },
|
|
];
|
|
timer = setTimeout(() => {
|
|
cancel();
|
|
togglePanel();
|
|
}, LONGPRESS_MS);
|
|
},
|
|
opts
|
|
);
|
|
|
|
// A pinch-zoom is two fingers held down, so the slop check is what keeps a
|
|
// normal-speed one from opening the panel mid-gesture. Verified 2026-07-28:
|
|
// a pinch that reaches 240px in ~240ms cancels well before the timer. A
|
|
// deliberately slow pinch that stays inside the slop for the full 500ms
|
|
// does open the panel — accepted, since an absolute per-finger drift
|
|
// threshold cannot tell that apart from two fingers resting, which is the
|
|
// gesture this is for.
|
|
document.addEventListener(
|
|
"touchmove",
|
|
(e) => {
|
|
if (!start || e.touches.length !== 2) return cancel();
|
|
for (let i = 0; i < 2; i++) {
|
|
if (
|
|
Math.abs(e.touches[i].clientX - start[i].x) > LONGPRESS_SLOP ||
|
|
Math.abs(e.touches[i].clientY - start[i].y) > LONGPRESS_SLOP
|
|
) {
|
|
return cancel();
|
|
}
|
|
}
|
|
},
|
|
opts
|
|
);
|
|
|
|
document.addEventListener("touchend", cancel, opts);
|
|
document.addEventListener("touchcancel", cancel, opts);
|
|
}
|
|
|
|
// ============================================================
|
|
// Boot
|
|
// ============================================================
|
|
|
|
function init() {
|
|
buildUI();
|
|
state.page = detect();
|
|
render();
|
|
installNavWatcher();
|
|
installLongPress();
|
|
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="Novel bookmarks"><span id="fill"></span><span id="hit"></span></button>
|
|
<div id="backdrop"></div>
|
|
<aside id="panel" role="dialog" aria-label="Novel bookmarks">
|
|
<header>
|
|
<span class="brand">
|
|
<svg class="mark" viewBox="0 0 200 172" aria-hidden="true">
|
|
<g fill="var(--ink)" stroke="currentColor" stroke-width="6.5" stroke-linejoin="round" stroke-linecap="round">
|
|
<path fill="none" d="M28 36H4v114h192V36h-24"></path>
|
|
<path fill="none" d="M28 23H17v127h166V23h-11"></path>
|
|
<g id="mb-half">
|
|
<path d="M28 7 88 55v97L28 138z"></path>
|
|
<g fill="currentColor" stroke="none">
|
|
<path d="M37 25 55 39v41L37 66z"></path>
|
|
<path d="M60 42 79 57v42L60 84z"></path>
|
|
<path d="M37 75 79 108v13L37 88z"></path>
|
|
<path d="M37 98 79 129v11L37 131z"></path>
|
|
</g>
|
|
</g>
|
|
<use href="#mb-half" transform="matrix(-1 0 0 1 200 0)"></use>
|
|
<g stroke="var(--ember)">
|
|
<path d="M100 4l9 5v11l-9 5-9-5V9z"></path>
|
|
<path d="M94 24h12v24H94z"></path>
|
|
<path d="M70 47h60v14H70z"></path>
|
|
<path d="M91 61h18v87l-9 20-9-20z"></path>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
<span>Bookmark<em>Manager</em></span>
|
|
</span>
|
|
<button id="closeBtn" aria-label="Close">close</button>
|
|
</header>
|
|
<div id="nav">
|
|
<a class="chip" href="${WEB_BASE}/?lib=novel" target="_blank" rel="noopener">Web</a>
|
|
<a class="chip" href="https://novelfull.com" target="_blank" rel="noopener">NovelFull</a>
|
|
<a class="chip" href="https://lightnovelworld.net" target="_blank" rel="noopener">LNW</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 = `
|
|
/* Cinder, ported to the panel: same tokens and the same rule that heat is
|
|
typographic. Fonts are system stacks, not the web UI's Google fonts — an
|
|
@import here would be at the mercy of the host page's CSP.
|
|
See docs/design-system.md. */
|
|
:host {
|
|
all: initial;
|
|
--ink: #100f0e; --ash: #161413; --dim: #0d0c0b;
|
|
--rule: #221f1d; --field-line: #2c2926; --hover: #1a1816;
|
|
--paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb;
|
|
--mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b;
|
|
--ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907;
|
|
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450;
|
|
--font-display: Georgia, "Times New Roman", serif;
|
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
--font-body: system-ui, -apple-system, sans-serif;
|
|
}
|
|
* { box-sizing: border-box; font-family: var(--font-body); }
|
|
#fab {
|
|
position: fixed;
|
|
width: 7px; height: 44px; border: none; padding: 0;
|
|
background: var(--ember); opacity: .5;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,.5); z-index: 2147483646; cursor: pointer;
|
|
touch-action: none; user-select: none; -webkit-touch-callout: none;
|
|
}
|
|
#fab:active, #fab.armed { opacity: 1; }
|
|
#fab.armed { box-shadow: 0 0 0 2px var(--ember-soft); }
|
|
#fill {
|
|
position: absolute; left: 0; right: 0; bottom: 0; height: 0;
|
|
background: var(--paper); pointer-events: none;
|
|
}
|
|
/* Invisible tap extension: the visible sliver is 7x44, this makes it 28x72.
|
|
It grows inward (toward the screen centre) because the tab is docked flush
|
|
against an edge, so outward is off-screen. */
|
|
#hit {
|
|
position: absolute; top: -14px; bottom: -14px;
|
|
left: -21px; right: 0;
|
|
}
|
|
#fab.left #hit { left: 0; right: -21px; }
|
|
#backdrop {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
|
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: var(--ink); color: var(--paper-dim);
|
|
transform: translateX(105%); transition: transform .25s ease;
|
|
z-index: 2147483647; display: flex; flex-direction: column;
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
border-left: 1px solid var(--rule);
|
|
box-shadow: -4px 0 20px rgba(0,0,0,.6);
|
|
}
|
|
#panel.open { transform: translateX(0); }
|
|
header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 16px; border-bottom: 1px solid var(--rule);
|
|
font: 400 22px/1 var(--font-display); color: var(--paper);
|
|
}
|
|
header em { color: var(--ember); font-style: italic; }
|
|
.brand { display: flex; align-items: center; gap: 9px; }
|
|
/* 5px stroke on a 200-unit grid thins out at this size: the markup carries
|
|
6.5 instead. overflow visible keeps the topmost blade pip from clipping. */
|
|
.mark { width: 26px; height: 22px; flex: none; overflow: visible; }
|
|
#closeBtn {
|
|
background: none; border: none; color: var(--mute); cursor: pointer;
|
|
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
|
border-bottom: 1px solid var(--field-line); padding: 0 0 2px;
|
|
}
|
|
#closeBtn:active { color: var(--paper); }
|
|
#nav { display: flex; gap: 14px; padding: 11px 16px; border-bottom: 1px solid var(--rule); }
|
|
/* Chips are set as small caps rules, not pills — the sheet has no corners. */
|
|
.chip {
|
|
background: none; border: none; padding: 0 0 2px;
|
|
border-bottom: 1px solid var(--field-line);
|
|
color: var(--mute); text-decoration: none; cursor: pointer;
|
|
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
|
white-space: nowrap;
|
|
}
|
|
.chip:active { color: var(--paper); border-bottom-color: var(--paper); }
|
|
.chip.pending { color: var(--ember); border-bottom-color: var(--ember); }
|
|
.chip.pending[hidden] { display: none; }
|
|
#context {
|
|
padding: 14px 16px; border-bottom: 1px solid var(--rule);
|
|
display: flex; flex-direction: column; gap: 9px; align-items: flex-start;
|
|
}
|
|
.ctx-title { font: 400 19px/1.2 var(--font-display); color: var(--paper); }
|
|
.ctx-sub {
|
|
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em;
|
|
text-transform: uppercase; color: var(--mute);
|
|
}
|
|
#tabs { display: flex; gap: 16px; padding: 2px 16px 0; border-bottom: 1px solid var(--rule); }
|
|
.tab {
|
|
background: none; border: none; cursor: pointer; color: var(--mute);
|
|
font: 400 16px var(--font-display); padding: 8px 0 12px;
|
|
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
|
}
|
|
.tab.active { color: var(--paper); border-bottom-color: var(--paper); }
|
|
#list { overflow-y: auto; flex: 1; }
|
|
.empty {
|
|
color: var(--mute); padding: 34px 16px;
|
|
font: 400 15px/1.6 var(--font-body);
|
|
}
|
|
/* Loading is the web UI's burning hairline, laid flat across the list. */
|
|
.spinner {
|
|
width: 100%; height: 1px; background: var(--rule); overflow: hidden; position: relative;
|
|
}
|
|
.spinner::after {
|
|
content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 30%;
|
|
background: var(--ember); animation: barSlide 1.15s linear infinite;
|
|
}
|
|
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } }
|
|
.item {
|
|
display: flex; flex-direction: row; gap: 12px;
|
|
padding: 14px 16px; border-bottom: 1px solid var(--rule);
|
|
}
|
|
.item.dim { background: var(--dim); }
|
|
.go { display: block; flex: none; }
|
|
.go:active, .go-t:active { opacity: .7; }
|
|
.go-t { display: block; text-decoration: none; color: inherit; }
|
|
.cover {
|
|
width: 52px; height: 70px; object-fit: cover; flex: none;
|
|
background: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px);
|
|
}
|
|
.cover.ph { display: block; }
|
|
.item.dim .cover { filter: grayscale(1); opacity: .85; }
|
|
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; }
|
|
.t {
|
|
font: 400 17px/1.2 var(--font-display); color: var(--paper-dim);
|
|
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; }
|
|
.item.dim .t { font-style: italic; color: var(--mute); }
|
|
.c {
|
|
margin: 0; color: var(--mute);
|
|
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
|
}
|
|
.c.behind { color: var(--ember); }
|
|
.actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; }
|
|
.btn {
|
|
background: none; color: var(--mute); border: 1px solid var(--rule);
|
|
margin: 0 -1px -1px 0; padding: 0 12px; height: 38px; line-height: 36px;
|
|
cursor: pointer; text-decoration: none; display: inline-block;
|
|
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
|
}
|
|
.btn:active { color: var(--paper); background: var(--hover); }
|
|
.btn.primary {
|
|
border-color: var(--paper); background: var(--paper); color: var(--ink);
|
|
font: 400 16px var(--font-display); letter-spacing: 0; text-transform: none;
|
|
height: 44px; line-height: 42px; padding: 0 18px;
|
|
}
|
|
.btn.small { height: 34px; line-height: 32px; padding: 0 10px; }
|
|
.btn.danger { color: var(--trash); }
|
|
.btn.danger:active { color: var(--ember); background: var(--ember-wash); }
|
|
.btn.star { font-size: 13px; letter-spacing: 0; }
|
|
.btn.star.on { color: var(--brass); border-color: #332b14; }
|
|
#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: var(--ash); color: var(--paper); padding: 12px 16px;
|
|
border-left: 2px solid var(--paper);
|
|
font: 400 16px var(--font-display);
|
|
opacity: 0; transform: translateY(10px); transition: all .3s;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 80vw;
|
|
}
|
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
.toast.err { background: var(--ember-wash); border-left-color: var(--ember); color: var(--ember-soft); }
|
|
`;
|
|
|
|
// ============================================================
|
|
// Test hook
|
|
//
|
|
// Fires only in Node, where window is undefined. The typeof window check keeps it
|
|
// inert on pages whose own scripts define a UMD module shim — under @grant none
|
|
// the userscript shares page globals, so we must not clobber the page's exports.
|
|
// Exposes pure logic only — see userscript/test/novel-logic.test.js.
|
|
// ============================================================
|
|
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
|
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe };
|
|
}
|
|
|
|
// ============================================================
|
|
// 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);
|
|
})();
|