diff --git a/userscript/AGENTS.md b/userscript/AGENTS.md index 98292f6..6428d10 100644 --- a/userscript/AGENTS.md +++ b/userscript/AGENTS.md @@ -44,3 +44,21 @@ Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `A Encodings (incl. triple-encoded punctuation like `%25252D`) identical on /manga/ and /title/ pages, so decode-once seriesIds match — verified 2026-07-28. +- **novelfull.com** (novel script): series `/.html`, chapter + `//chapter-[-].html`. No `og:*` tags at all — title from + `h3.title` (series) or `a.truyen-title` (chapter), cover from + `meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint + clears, so the backend polls it through the headless browser. +- **lightnovelworld.net** (novel script): series `/novel//`, chapter + `/-chapter-/` — flat, at the site root. `h1.entry-title` is the clean + title on a series page and ` Chapter <n>` on a chapter page. Chapter + pages carry no `og:image`. Its series page lists every chapter with an + absolute href, so the backend polls it with the plain TLS client. + +### Second script: `novel-bookmark.user.js` + +A copy of the manga script with two adapters, `LIBRARY = "novel"` and +`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous +installation to carry keys over from). Installed alongside the manga script; +both write to the same backend with the same `LIBRARY` column discriminating +them. diff --git a/userscript/CLAUDE.md b/userscript/CLAUDE.md index 5c22a98..608d8a0 100644 --- a/userscript/CLAUDE.md +++ b/userscript/CLAUDE.md @@ -44,3 +44,21 @@ Guidance for Claude Code working under `userscript/`. See root `CLAUDE.md` for t Encodings (incl. triple-encoded punctuation like `%25252D`) identical on /manga/ and /title/ pages, so decode-once seriesIds match — verified 2026-07-28. +- **novelfull.com** (novel script): series `/<slug>.html`, chapter + `/<slug>/chapter-<n>[-<title-slug>].html`. No `og:*` tags at all — title from + `h3.title` (series) or `a.truyen-title` (chapter), cover from + `meta[name="image"]`. Behind a Cloudflare JS challenge no TLS fingerprint + clears, so the backend polls it through the headless browser. +- **lightnovelworld.net** (novel script): series `/novel/<slug>/`, chapter + `/<slug>-chapter-<n>/` — flat, at the site root. `h1.entry-title` is the clean + title on a series page and `<Title> Chapter <n>` on a chapter page. Chapter + pages carry no `og:image`. Its series page lists every chapter with an + absolute href, so the backend polls it with the plain TLS client. + +### Second script: `novel-bookmark.user.js` + +A copy of the manga script with two adapters, `LIBRARY = "novel"` and +`STORE_PREFIX = "bmgr:novel:"`. No migration loop (this script has no previous +installation to carry keys over from). Installed alongside the manga script; +both write to the same backend with the same `LIBRARY` column discriminating +them. diff --git a/userscript/novel-bookmark.user.js b/userscript/novel-bookmark.user.js new file mode 100644 index 0000000..df82b71 --- /dev/null +++ b/userscript/novel-bookmark.user.js @@ -0,0 +1,1590 @@ +// ==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/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/novel-bookmark.user.js +// @updateURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/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 = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN + 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:"; + + // 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); title/cover come from + // og: meta tags (with the novelfull name= meta as the exception). + // ============================================================ + + + 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; + } + + // novelfull ships no og: tags at all — its cover lives on a name= meta. + function metaName(name) { + const el = document.querySelector('meta[name="' + name + '"]'); + return el ? el.getAttribute("content") : null; + } + + 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() : "", + cover: metaName("image") || "", + 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() : "", + cover: metaName("image") || "", + 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.]+)")); + }, + }; + + 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) { + const num = parseFloat(m[2]); + const h1 = document.querySelector("h1.entry-title"); + const heading = h1 ? h1.textContent || "" : ""; + return { + type: "chapter", + site: this.site, + seriesId: m[1], + // The heading is "<Series> Chapter <n>"; drop the suffix. + title: heading.replace(/\s*Chapter\s+[0-9.]+\s*$/i, "").trim(), + // Chapter pages carry no og:image. Empty is safe: every write merges + // against the cached row, which keeps the cover the series page gave. + cover: "", + seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/", + 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], + title: h1 ? (h1.textContent || "").trim() : "", + cover: meta("og:image") || "", + 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; + 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) || "", + 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, + kind: LIBRARY, + 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 || 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: "" }) + : 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); +})(); diff --git a/userscript/test/novel-logic.test.js b/userscript/test/novel-logic.test.js new file mode 100644 index 0000000..7293764 --- /dev/null +++ b/userscript/test/novel-logic.test.js @@ -0,0 +1,182 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert"); + +// ============================================================ +// Minimal browser stub. Same shape as logic.test.js, plus a meta[name=...] +// branch: novelfull ships no og: tags, so its cover comes from name="image". +// document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded +// that never fires and no network call is ever made. +// ============================================================ + +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; + +globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" }; + +let metaTags = {}; +let namedMetas = {}; +let elements = {}; +globalThis.document = { + querySelector(sel) { + let m = sel.match(/^meta\[property="([^"]+)"\]$/); + if (m) { + const v = metaTags[m[1]]; + return v == null ? null : { getAttribute: () => v }; + } + m = sel.match(/^meta\[name="([^"]+)"\]$/); + if (m) { + const v = namedMetas[m[1]]; + return v == null ? null : { getAttribute: () => v }; + } + const text = elements[sel]; + return text == null ? null : { textContent: text }; + }, + querySelectorAll() { + return []; + }, + addEventListener() {}, + body: undefined, +}; + +const { + novelfull, + lightnovelworld, + kindOf, + maxChapter, +} = require("../novel-bookmark.user.js"); + +function loc(href) { + const u = new URL(href); + return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname }; +} + +function reset() { + metaTags = {}; + namedMetas = {}; + elements = {}; +} + +// ============================================================ +// novelfull adapter +// ============================================================ + +test("novelfull.detect reads a series page", () => { + reset(); + namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" }; + elements = { "h3.title": "Reverend Insanity" }; + const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html")); + assert.equal(p.type, "series"); + assert.equal(p.site, "novelfull"); + assert.equal(p.seriesId, "reverend-insanity"); + assert.equal(p.title, "Reverend Insanity"); + assert.equal(p.cover, "https://novelfull.com/uploads/thumbs/ri.jpg"); + assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html"); + assert.equal(p.chapterNum, null); +}); + +test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => { + reset(); + namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" }; + elements = { "a.truyen-title": "Reverend Insanity" }; + const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html"; + const p = novelfull.detect(loc(url)); + assert.equal(p.type, "chapter"); + assert.equal(p.seriesId, "reverend-insanity"); + assert.equal(p.chapterNum, 2334); + assert.equal(p.chapterLabel, "Chapter 2334"); + assert.equal(p.chapterUrl, url); + assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html"); + assert.equal(p.title, "Reverend Insanity"); +}); + +test("novelfull.detect returns other for non-series paths", () => { + reset(); + assert.equal(novelfull.detect(loc("https://novelfull.com/")).type, "other"); + assert.equal(novelfull.detect(loc("https://novelfull.com/genre/Fantasy")).type, "other"); +}); + +test("novelfull.latestChapterFromAnchors takes the max and ignores other series", () => { + const best = novelfull.latestChapterFromAnchors( + [ + { href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" }, + { href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" }, + { href: "/reverend-insanity/chapter-2.html", text: "Chapter 2" }, + { href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" }, + ], + "reverend-insanity" + ); + assert.deepEqual(best, { num: 2334, label: "Chapter 2334" }); +}); + +// ============================================================ +// lightnovelworld adapter +// ============================================================ + +test("lightnovelworld.detect reads a series page", () => { + reset(); + metaTags = { "og:image": "https://lightnovelworld.net/wp-content/uploads/awe.webp" }; + elements = { "h1.entry-title": "A Will Eternal" }; + const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/")); + assert.equal(p.type, "series"); + assert.equal(p.site, "lightnovelworld"); + assert.equal(p.seriesId, "a-will-eternal"); + assert.equal(p.title, "A Will Eternal"); + assert.equal(p.cover, "https://lightnovelworld.net/wp-content/uploads/awe.webp"); +}); + +test("lightnovelworld.detect strips the chapter suffix off the heading", () => { + reset(); + elements = { "h1.entry-title": "A Will Eternal Chapter 1298" }; + const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/"; + const p = lightnovelworld.detect(loc(url)); + assert.equal(p.type, "chapter"); + assert.equal(p.seriesId, "a-will-eternal"); + assert.equal(p.chapterNum, 1298); + assert.equal(p.chapterLabel, "Chapter 1298"); + assert.equal(p.title, "A Will Eternal"); + assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/"); + // Chapter pages have no cover; the merge in bookmarkCurrent keeps the stored one. + assert.equal(p.cover, ""); +}); + +test("lightnovelworld.detect returns other for non-series paths", () => { + reset(); + assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other"); + assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/az-lists/")).type, "other"); +}); + +test("lightnovelworld.latestChapterFromAnchors takes the max and ignores other series", () => { + const best = lightnovelworld.latestChapterFromAnchors( + [ + { href: "https://lightnovelworld.net/a-will-eternal-chapter-1/", text: "Chapter 1" }, + { href: "https://lightnovelworld.net/a-will-eternal-chapter-1317/", text: "Chapter 1317" }, + { href: "https://lightnovelworld.net/a-will-eternal-chapter-1298/", text: "Chapter 1298" }, + { href: "https://lightnovelworld.net/overgeared-chapter-9999/", text: "Chapter 9999" }, + ], + "a-will-eternal" + ); + assert.deepEqual(best, { num: 1317, label: "Chapter 1317" }); +}); + +test("latestChapterFromAnchors returns null when nothing matches", () => { + assert.equal(novelfull.latestChapterFromAnchors([{ href: "/about", text: "About" }], "x"), null); + assert.equal(maxChapter([], /chapter-([0-9.]+)/), null); +}); + +// ============================================================ +// kindOf +// ============================================================ + +test("kindOf defaults a missing kind to manga", () => { + assert.equal(kindOf({}), "manga"); +}); + +test("kindOf passes through novel", () => { + assert.equal(kindOf({ kind: "novel" }), "novel"); +});