8e4fa6448e
Closes #136. Spec #136 end to end: `finished` becomes a fact about the Series, written only by the owner, and the reader-facing Lifecycle bucket is gone. ## What landed - **#157** — `series.finished_at bigint NOT NULL DEFAULT 0` plus the migration whose statement order is load-bearing (seed from the buckets, then flip them); both Lane queries lose the `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished')` clause and gate on `finished_at = 0` instead, with the due-query/eligible-count force asymmetry kept deliberate and commented; `StatusFinished`, its API special-case 400, the web tab and the templates' Finished bucket deleted. - **#158** — owner Finish control on the Series detail page: confirm-gated finish, instant un-finish, admin accent (never ember, nothing is destroyed), `Store.SetSeriesFinished`, the two routes behind the owner gate, and the state displayed on the list row without offering the control there. - **#160** — reader side: derived `finished` bool on the flat Bookmark (`s.finished_at > 0`), rendered as a text-only label in both userscripts and on the web card; read-only inbound by omission from `Upsert`'s explicit `series` column list, same mechanism that already protects `cover`. - **#161** — glossary and the stale Reader-count divergence note catch up. - **#159** — `finished` joins the admin filter vocabulary (predicate `finished_at > 0`, label `Finished`, own aggregate count, figure last in the stats block as informational); the four clock-driven hygiene predicates (stale, never-checked, no-cover, no-chapter) exclude finished Series while unpollable, orphan and sighting-raised deliberately do not. ## Verification `go vet ./...` and `go test ./...` green on the merged branch (Docker-backed, throwaway `postgres:17-alpine` per package). Each ticket also passed a two-axis review (spec + standards) on its own branch before merge. Reviewed-on: #163 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
1791 lines
69 KiB
JavaScript
1791 lines
69 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.]+)"));
|
|
},
|
|
};
|
|
|
|
// The host shape this adapter owns. One definition so the pointer validator
|
|
// and `matches` cannot drift apart (a leading subdomain is allowed).
|
|
const lnwHostRe = /(^|\.)lightnovelworld\.net$/;
|
|
|
|
// A chapter page's pointer is its own link back to its Series. The href is
|
|
// page markup, so validate before trusting: resolve it against the page
|
|
// address, require the host above and the /novel/<slug>/ Series path.
|
|
// Anything else is not a pointer.
|
|
function seriesIdFromLnwPointer(href, base) {
|
|
if (!href) return null;
|
|
let u;
|
|
try {
|
|
u = new URL(href, base);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
if (!lnwHostRe.test(u.hostname)) return null;
|
|
const m = u.pathname.match(/^\/novel\/([^/]+)\/?$/);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
const lightnovelworld = {
|
|
site: "lightnovelworld",
|
|
matches: (loc) => lnwHostRe.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"), loc.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"), loc.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" };
|
|
},
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
// A stale lightnovelworld row is one keyed under the Chapter Slug its
|
|
// address was built from, while the page's pointer names a different
|
|
// Series. All three storage sites must move together: the cache row alone
|
|
// leaves a queued write replaying under a key whose row no longer exists
|
|
// (that write is silently lost), and a last-checked timestamp left behind
|
|
// re-fetches the repaired row on the next visit. A repair must never cost a
|
|
// Reader the chapter they were tracking, so when a row already holds the
|
|
// repaired key the two rows merge and the farther-ahead progress wins.
|
|
// Pure: no storage access, no module state, no clock.
|
|
function repairLnwStaleRow(list, queue, lastChecked, page) {
|
|
if (
|
|
!page ||
|
|
page.site !== "lightnovelworld" ||
|
|
!page.chapterSlug ||
|
|
!page.seriesId ||
|
|
page.chapterSlug === page.seriesId
|
|
) {
|
|
return { list, queue, lastChecked };
|
|
}
|
|
const oldKey = "lightnovelworld:" + page.chapterSlug;
|
|
const newKey = "lightnovelworld:" + page.seriesId;
|
|
const stale = list.find((b) => b.key === oldKey);
|
|
if (!stale) return { list, queue, lastChecked };
|
|
|
|
let listOut;
|
|
const existing = list.find((b) => b.key === newKey);
|
|
if (existing) {
|
|
// Duplicate case (spec user story 2): one row must survive, and it is
|
|
// the one already under the repaired key — with the stale row's
|
|
// progress carried across when it is ahead, favourite OR'd, and the
|
|
// stronger lifecycle bucket kept (archived > reading, so a merge can
|
|
// never silently un-archive a row). finished is not a bucket the
|
|
// script can see (issue #157).
|
|
const merged = Object.assign({}, existing);
|
|
if (
|
|
existing.last_chapter_num == null ||
|
|
(stale.last_chapter_num != null && stale.last_chapter_num > existing.last_chapter_num)
|
|
) {
|
|
merged.last_chapter = stale.last_chapter;
|
|
merged.last_chapter_num = stale.last_chapter_num;
|
|
merged.last_chapter_url = stale.last_chapter_url;
|
|
}
|
|
merged.favorite = !!(existing.favorite || stale.favorite);
|
|
const rank = (s) => ({ archived: 1, reading: 0 }[s || "reading"] || 0);
|
|
merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status;
|
|
merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0);
|
|
listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b));
|
|
} else {
|
|
listOut = list.map((b) =>
|
|
b.key === oldKey
|
|
? Object.assign({}, b, {
|
|
key: newKey,
|
|
series_id: page.seriesId,
|
|
series_url: page.seriesUrl,
|
|
})
|
|
: b
|
|
);
|
|
}
|
|
|
|
let queueOut = queue;
|
|
const oldEntry = queue.find((e) => e.key === oldKey);
|
|
if (oldEntry) {
|
|
const survivor = queue.find((x) => x.key === newKey);
|
|
if (!survivor) {
|
|
queueOut = queue.map((e) => (e.key === oldKey ? Object.assign({}, e, { key: newKey }) : e));
|
|
} else {
|
|
// Both keys hold a marker and one row survives, so the two collapse
|
|
// into one entry under the repaired key (the queue's one-entry-per-key
|
|
// invariant). sendStatus is sticky — an archive intent from either
|
|
// marker survives, the queue's own rule — and the worse attempts
|
|
// count wins. The stale-key marker's op is dropped: the row it
|
|
// described is retired by the merge itself.
|
|
queueOut = queue
|
|
.filter((e) => e.key !== oldKey && e.key !== newKey)
|
|
.concat([
|
|
{
|
|
key: newKey,
|
|
op: survivor.op,
|
|
sendStatus: survivor.sendStatus || oldEntry.sendStatus,
|
|
attempts: Math.max(survivor.attempts || 0, oldEntry.attempts || 0),
|
|
},
|
|
]);
|
|
}
|
|
}
|
|
|
|
let lastCheckedOut = lastChecked;
|
|
if (oldKey in lastChecked) {
|
|
lastCheckedOut = Object.assign({}, lastChecked);
|
|
// Max, not overwrite: a timestamp already under the repaired key must
|
|
// not be rolled back to an older one.
|
|
lastCheckedOut[newKey] = Math.max(lastCheckedOut[newKey] || 0, lastCheckedOut[oldKey]);
|
|
delete lastCheckedOut[oldKey];
|
|
}
|
|
|
|
return { list: listOut, queue: queueOut, lastChecked: lastCheckedOut };
|
|
}
|
|
|
|
// 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. An adapter without a scanner (lightnovelworld — see the
|
|
// AGENTS.md entry) yields null, not an error.
|
|
function computeLatestChapter(site, anchors, seriesId) {
|
|
const a = adapterFor(site);
|
|
return a && a.latestChapterFromAnchors ? 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 a stale status (the API rejects
|
|
// unknown values 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";
|
|
}
|
|
|
|
// finished is a fact about the Series, decided from the owner's side and
|
|
// derived on the wire (issue #157); the panel only labels it.
|
|
function finishedLabel(b) {
|
|
return b && b.finished ? "Finished" : "";
|
|
}
|
|
|
|
// ============================================================
|
|
// 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 not a bucket the script can
|
|
// reach — it is a fact about the Series now (issue #157).
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Reports 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.
|
|
//
|
|
// An unchanged number is still sent. It is the read that lets the backend
|
|
// skip its own poll of this series (a Sighting, issue #103), so the common
|
|
// case — visiting a series with nothing new — is exactly the one worth
|
|
// reporting. Only the local write and the re-render are skipped.
|
|
async function reportLatestChapter(existing, latest) {
|
|
if (!existing || !latest) return;
|
|
const changed = existing.latest_chapter_num !== latest.num;
|
|
let bm = existing;
|
|
if (changed) {
|
|
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);
|
|
if (changed) {
|
|
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;
|
|
reportLatestChapter(
|
|
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;
|
|
|
|
// A Site with no client scanner (lightnovelworld) is refreshed by the Poll
|
|
// on a cooldown shorter than the client throttle; fetching its pages here
|
|
// would be a megabyte-scale request whose result is discarded. Skipping
|
|
// before the due filter records no freshness timestamp and consumes no
|
|
// per-navigation batch slot.
|
|
const adapter = adapterFor(site);
|
|
if (!adapter || !adapter.latestChapterFromAnchors) return;
|
|
|
|
const checked = loadLastChecked();
|
|
const now = Date.now();
|
|
const due = state.list
|
|
.filter((b) => b.site === site && b.series_url)
|
|
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
|
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
|
.slice(0, LATEST_CHECK_BATCH);
|
|
if (due.length === 0) return;
|
|
|
|
for (const bm of due) {
|
|
// Recorded even when the fetch fails, so a broken series is retried on
|
|
// the next throttle window rather than on every single page load.
|
|
checked[bm.key] = Date.now();
|
|
try {
|
|
const res = await fetch(bm.series_url, { credentials: "same-origin" });
|
|
if (!res.ok) continue;
|
|
const html = await res.text();
|
|
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
|
|
await reportLatestChapter(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; a Series that is
|
|
// finished is the web UI's business and arrives archived (issue #157).
|
|
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 }),
|
|
b.finished && el("span", { class: "finished", text: finishedLabel(b) }),
|
|
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(awaitFirst) {
|
|
if (awaitFirst) await awaitFirst; // repair sync lands before we adopt the server's view of it
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Runs the repair on every navigation, silently: rewrites a stale
|
|
// lightnovelworld row to the page's discovered identity, persists all three
|
|
// sites, and syncs through the queue-backed path so an offline repair parks
|
|
// and replays later. No toast — the Reader never asked for this. Returns
|
|
// the sync promise (or null when nothing changed) so init can hold the
|
|
// server view until the repair has landed. When the transform returns its
|
|
// inputs unchanged there is nothing to do.
|
|
function applyLnwStaleRowRepair() {
|
|
const lastChecked = loadLastChecked();
|
|
const out = repairLnwStaleRow(state.list, queue, lastChecked, state.page);
|
|
if (out.list === state.list && out.queue === queue && out.lastChecked === lastChecked) {
|
|
return null;
|
|
}
|
|
state.list = out.list;
|
|
reindex(); // byKey answers under the old key until rebuilt
|
|
saveCache(state.list);
|
|
queue.splice(0, queue.length, ...out.queue); // closures hold this array instance
|
|
saveQueue(queue);
|
|
saveLastChecked(out.lastChecked);
|
|
// Queue-backed sync, outcome swallowed: the repaired row is PUT (with its
|
|
// bucket when archived — the only status a userscript write may send) and
|
|
// the old server bookmark is deleted so no duplicate survives on the
|
|
// wire. The delete parks under the retired key while offline — the
|
|
// teardown of the old identity, not a new record of the Chapter Slug.
|
|
const key = keyOf(state.page);
|
|
return Promise.all([
|
|
pushBookmark(key, statusOf(state.byKey[key]) === "archived"),
|
|
pushDelete("lightnovelworld:" + state.page.chapterSlug),
|
|
]).catch(() => {});
|
|
}
|
|
|
|
let lastUrl = location.href;
|
|
function onNavigate() {
|
|
state.page = detect();
|
|
applyLnwStaleRowRepair();
|
|
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();
|
|
const repair = applyLnwStaleRowRepair();
|
|
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(repair).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; --moss: #7fae86;
|
|
--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); }
|
|
/* finished: a Series fact, not a state class (issue #157) — same meta
|
|
typography as .c, moss instead of mute. */
|
|
.finished {
|
|
margin: 0; color: var(--moss);
|
|
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
|
}
|
|
.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, computeLatestChapter, repairLnwStaleRow, finishedLabel };
|
|
}
|
|
|
|
// ============================================================
|
|
// 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);
|
|
})();
|