Leaner userscript: edge tab, card-click continue, test harness #7
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Manga Bookmark Sync
|
// @name Manga Bookmark Sync
|
||||||
// @namespace mangabm
|
// @namespace mangabm
|
||||||
// @version 1.4.0
|
// @version 1.5.0
|
||||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||||
// @author you
|
// @author you
|
||||||
// @match https://asuracomic.net/*
|
// @match https://asuracomic.net/*
|
||||||
@@ -677,19 +677,6 @@
|
|||||||
await syncUpsert(bm, "Progress updated");
|
await syncUpsert(bm, "Progress updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual override from the panel input — forces any value.
|
|
||||||
async function setChapterManual(key, label, num, url) {
|
|
||||||
const existing = state.byKey[key];
|
|
||||||
if (!existing) return;
|
|
||||||
const bm = Object.assign({}, existing, {
|
|
||||||
last_chapter: label,
|
|
||||||
last_chapter_num: isNaN(num) ? existing.last_chapter_num : num,
|
|
||||||
last_chapter_url: url || existing.last_chapter_url,
|
|
||||||
updated_at: Date.now(),
|
|
||||||
});
|
|
||||||
await syncUpsert(bm, "Progress set");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncUpsert(bm, okMsg) {
|
async function syncUpsert(bm, okMsg) {
|
||||||
upsertLocal(bm); // optimistic
|
upsertLocal(bm); // optimistic
|
||||||
render();
|
render();
|
||||||
@@ -808,8 +795,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
|
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
|
||||||
// against a misclick on "latest chapter"). Arm a 25s timer instead; the countdown
|
// against a misclick on "latest chapter"). Arm a 25s timer instead; the tab fills
|
||||||
// ring around the FAB shows it running. The manual "Update to X" button still fires
|
// 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
|
// 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.
|
// the same chapter (Demonic /chapter/N/<page>) keeps it counting rather than resetting.
|
||||||
const DWELL_MS = 25000;
|
const DWELL_MS = 25000;
|
||||||
@@ -910,7 +897,8 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const FAB_KEY = "mangabm:fabpos";
|
const FAB_KEY = "mangabm:fabpos";
|
||||||
const FAB_MARGIN = 12;
|
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
|
||||||
|
|
||||||
function clamp(v, lo, hi) {
|
function clamp(v, lo, hi) {
|
||||||
return Math.max(lo, Math.min(hi, v));
|
return Math.max(lo, Math.min(hi, v));
|
||||||
@@ -919,11 +907,12 @@
|
|||||||
function applyFabPos(fab, side, y) {
|
function applyFabPos(fab, side, y) {
|
||||||
fab.style.top = y + "px";
|
fab.style.top = y + "px";
|
||||||
fab.style.bottom = "auto";
|
fab.style.bottom = "auto";
|
||||||
|
fab.classList.toggle("left", side === "left");
|
||||||
if (side === "left") {
|
if (side === "left") {
|
||||||
fab.style.left = FAB_MARGIN + "px";
|
fab.style.left = FAB_EDGE + "px";
|
||||||
fab.style.right = "auto";
|
fab.style.right = "auto";
|
||||||
} else {
|
} else {
|
||||||
fab.style.right = FAB_MARGIN + "px";
|
fab.style.right = FAB_EDGE + "px";
|
||||||
fab.style.left = "auto";
|
fab.style.left = "auto";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,9 +934,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function placeFab(fab) {
|
function placeFab(fab) {
|
||||||
const s = fab.offsetWidth || 52;
|
const h = fab.offsetHeight || 44;
|
||||||
const p = loadFabPos();
|
const p = loadFabPos();
|
||||||
const maxY = window.innerHeight - s - FAB_MARGIN;
|
const maxY = window.innerHeight - h - FAB_MARGIN;
|
||||||
const side = p && p.side === "left" ? "left" : "right";
|
const side = p && p.side === "left" ? "left" : "right";
|
||||||
const y = p ? clamp(p.yRatio * window.innerHeight, FAB_MARGIN, maxY) : maxY - 8;
|
const y = p ? clamp(p.yRatio * window.innerHeight, FAB_MARGIN, maxY) : maxY - 8;
|
||||||
applyFabPos(fab, side, y);
|
applyFabPos(fab, side, y);
|
||||||
@@ -971,10 +960,10 @@
|
|||||||
if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true;
|
if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true;
|
||||||
if (!moved) return;
|
if (!moved) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const s = fab.offsetWidth;
|
const w = fab.offsetWidth, h = fab.offsetHeight;
|
||||||
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - s) + "px";
|
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - w) + "px";
|
||||||
fab.style.right = "auto";
|
fab.style.right = "auto";
|
||||||
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - s) + "px";
|
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px";
|
||||||
fab.style.bottom = "auto";
|
fab.style.bottom = "auto";
|
||||||
});
|
});
|
||||||
fab.addEventListener("pointerup", (e) => {
|
fab.addEventListener("pointerup", (e) => {
|
||||||
@@ -983,10 +972,10 @@
|
|||||||
fab.style.transition = "";
|
fab.style.transition = "";
|
||||||
if (!moved) return; // tap — let the click handler toggle the panel
|
if (!moved) return; // tap — let the click handler toggle the panel
|
||||||
fab.dataset.dragged = "1"; // suppress the click that trails this drag
|
fab.dataset.dragged = "1"; // suppress the click that trails this drag
|
||||||
const s = fab.offsetWidth;
|
const w = fab.offsetWidth, h = fab.offsetHeight;
|
||||||
const r = fab.getBoundingClientRect();
|
const r = fab.getBoundingClientRect();
|
||||||
const side = r.left + s / 2 < window.innerWidth / 2 ? "left" : "right";
|
const side = r.left + w / 2 < window.innerWidth / 2 ? "left" : "right";
|
||||||
const y = clamp(r.top, FAB_MARGIN, window.innerHeight - s - FAB_MARGIN);
|
const y = clamp(r.top, FAB_MARGIN, window.innerHeight - h - FAB_MARGIN);
|
||||||
fab.style.transition = "left .2s ease, right .2s ease, top .2s ease";
|
fab.style.transition = "left .2s ease, right .2s ease, top .2s ease";
|
||||||
applyFabPos(fab, side, y);
|
applyFabPos(fab, side, y);
|
||||||
saveFabPos(side, y / window.innerHeight);
|
saveFabPos(side, y / window.innerHeight);
|
||||||
@@ -994,31 +983,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Countdown ring around the FAB (dwell-timer progress)
|
// Dwell-timer progress: the tab fills bottom-up. A 7px sliver has no room
|
||||||
|
// for the ring this replaced.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const RING_C = 144.5; // circumference: 2π·23
|
|
||||||
|
|
||||||
function startRing(ms) {
|
function startRing(ms) {
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const ring = root.getElementById("ring");
|
const fill = root.getElementById("fill");
|
||||||
const prog = ring.firstElementChild;
|
fill.style.transition = "none";
|
||||||
ring.classList.add("active");
|
fill.style.height = "0";
|
||||||
prog.style.transition = "none";
|
fill.getBoundingClientRect(); // reflow so the next assignment animates
|
||||||
prog.style.strokeDashoffset = RING_C; // empty
|
fill.style.transition = "height " + ms + "ms linear";
|
||||||
prog.getBoundingClientRect(); // reflow so the next assignment animates
|
fill.style.height = "100%";
|
||||||
prog.style.transition = "stroke-dashoffset " + ms + "ms linear";
|
|
||||||
prog.style.strokeDashoffset = "0"; // fills over ms
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRing() {
|
function stopRing() {
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const ring = root.getElementById("ring");
|
const fill = root.getElementById("fill");
|
||||||
if (!ring) return;
|
if (!fill) return;
|
||||||
const prog = ring.firstElementChild;
|
fill.style.transition = "none";
|
||||||
ring.classList.remove("active");
|
fill.style.height = "0";
|
||||||
prog.style.transition = "none";
|
|
||||||
prog.style.strokeDashoffset = RING_C;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePanel() {
|
function togglePanel() {
|
||||||
@@ -1108,40 +1092,47 @@
|
|||||||
const sub = behind
|
const sub = behind
|
||||||
? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter
|
? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter
|
||||||
: (b.last_chapter || "—") + " · " + b.site;
|
: (b.last_chapter || "—") + " · " + b.site;
|
||||||
const row = el("div", { class: "item" }, [
|
// Cover and text are one link: the card body *is* the continue button, so
|
||||||
b.cover
|
// there is no separate one. The action row is a sibling of that link, never
|
||||||
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
// a child — a thumb that misses ★ must not land on a navigation target, and
|
||||||
: el("div", { class: "cover ph" }),
|
// Remove must never be one.
|
||||||
el("div", { class: "meta" }, [
|
return el("div", { class: "item" }, [
|
||||||
el("div", { class: "t", text: b.title || b.series_id }),
|
el("a", { class: "go", href: cont }, [
|
||||||
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
b.cover
|
||||||
el("div", { class: "actions" }, [
|
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
||||||
el("button", {
|
: el("div", { class: "cover ph" }),
|
||||||
class: "btn small star" + (b.favorite ? " on" : ""),
|
el("div", { class: "meta" }, [
|
||||||
text: b.favorite ? "★" : "☆",
|
el("div", { class: "t", text: b.title || b.series_id }),
|
||||||
title: b.favorite ? "Remove from favourites" : "Add to favourites",
|
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
||||||
onclick: () => toggleFavorite(b.key),
|
|
||||||
}),
|
|
||||||
el("button", {
|
|
||||||
class: "btn small",
|
|
||||||
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
|
|
||||||
onclick: () => toggleArchive(b.key),
|
|
||||||
}),
|
|
||||||
el("a", { class: "btn small", href: cont, text: "Continue" }),
|
|
||||||
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
|
|
||||||
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
|
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editChapter(b) {
|
// Removal syncs to every device, so it asks first. confirm() is the page's
|
||||||
const raw = prompt("Set last-read chapter for \"" + (b.title || b.series_id) + "\":", b.last_chapter || "");
|
// own dialog, not a shadow-root one — modal, and the site cannot style it.
|
||||||
if (raw == null) return;
|
function confirmRemove(b) {
|
||||||
const label = raw.trim();
|
if (confirm('Remove "' + (b.title || b.series_id) + '" from your bookmarks?')) {
|
||||||
const num = parseFloat((label.match(/[\d.]+/) || [])[0]);
|
removeBookmark(b.key);
|
||||||
setChapterManual(b.key, label, num, b.last_chapter_url);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toast inside the shadow root.
|
// Toast inside the shadow root.
|
||||||
@@ -1209,6 +1200,76 @@
|
|||||||
setInterval(fire, 1500); // catch routes that bypass history
|
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
|
// Boot
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1218,6 +1279,7 @@
|
|||||||
state.page = detect();
|
state.page = detect();
|
||||||
render();
|
render();
|
||||||
installNavWatcher();
|
installNavWatcher();
|
||||||
|
installLongPress();
|
||||||
window.addEventListener("online", drain); // signal returned while the page stayed open
|
window.addEventListener("online", drain); // signal returned while the page stayed open
|
||||||
// Sync first: both auto-record and the latest-chapter checks below need to
|
// Sync first: both auto-record and the latest-chapter checks below need to
|
||||||
// know which series are bookmarked and how fresh they are.
|
// know which series are bookmarked and how fresh they are.
|
||||||
@@ -1233,10 +1295,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const TEMPLATE = `
|
const TEMPLATE = `
|
||||||
<button id="fab" aria-label="Manga bookmarks">
|
<button id="fab" aria-label="Manga bookmarks"><span id="fill"></span></button>
|
||||||
<svg id="ring" viewBox="0 0 52 52" aria-hidden="true"><circle class="prog" cx="26" cy="26" r="23"></circle></svg>
|
|
||||||
<span class="fab-icon">📑</span>
|
|
||||||
</button>
|
|
||||||
<div id="backdrop"></div>
|
<div id="backdrop"></div>
|
||||||
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
||||||
<header>
|
<header>
|
||||||
@@ -1265,23 +1324,18 @@
|
|||||||
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
||||||
#fab {
|
#fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
width: 52px; height: 52px; border-radius: 50%; border: none;
|
width: 7px; height: 44px; border: none; padding: 0;
|
||||||
background: #6d28d9; color: #fff; font-size: 22px;
|
border-radius: 4px 0 0 4px;
|
||||||
box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
background: #6d28d9; opacity: .45;
|
||||||
touch-action: none; user-select: none;
|
box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
||||||
display: flex; align-items: center; justify-content: center; padding: 0;
|
touch-action: none; user-select: none; overflow: hidden;
|
||||||
}
|
}
|
||||||
#fab:active { transform: scale(.94); }
|
#fab.left { border-radius: 0 4px 4px 0; }
|
||||||
#ring {
|
#fab:active { opacity: 1; }
|
||||||
position: absolute; inset: 0; transform: rotate(-90deg);
|
#fill {
|
||||||
opacity: 0; transition: opacity .15s; pointer-events: none;
|
position: absolute; left: 0; right: 0; bottom: 0; height: 0;
|
||||||
|
background: #c4b5fd; pointer-events: none;
|
||||||
}
|
}
|
||||||
#ring.active { opacity: 1; }
|
|
||||||
#ring .prog {
|
|
||||||
fill: none; stroke: #c4b5fd; stroke-width: 3; stroke-linecap: round;
|
|
||||||
stroke-dasharray: 144.5; stroke-dashoffset: 144.5;
|
|
||||||
}
|
|
||||||
.fab-icon { position: relative; z-index: 1; line-height: 1; }
|
|
||||||
#backdrop {
|
#backdrop {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
||||||
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
|
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
|
||||||
@@ -1325,12 +1379,17 @@
|
|||||||
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
|
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
|
||||||
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
||||||
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
||||||
.item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; }
|
.item {
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
padding: 10px 16px; border-bottom: 1px solid #2a2a33;
|
||||||
|
}
|
||||||
|
.go { display: flex; gap: 10px; text-decoration: none; color: inherit; }
|
||||||
|
.go:active { opacity: .7; }
|
||||||
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
|
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
|
||||||
.cover.ph { display: block; }
|
.cover.ph { display: block; }
|
||||||
.meta { min-width: 0; flex: 1; }
|
.meta { min-width: 0; flex: 1; }
|
||||||
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; }
|
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 0; }
|
||||||
.c.behind { color: #c4b5fd; }
|
.c.behind { color: #c4b5fd; }
|
||||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.btn {
|
.btn {
|
||||||
@@ -1357,6 +1416,18 @@
|
|||||||
.toast.err { background: #7f1d1d; }
|
.toast.err { background: #7f1d1d; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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/logic.test.js.
|
||||||
|
// ============================================================
|
||||||
|
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
||||||
|
module.exports = { stripBuildHash, asura, demonic, anchorsFromHTML, statusOf };
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Boot — MUST run last: init() -> buildUI() reads TEMPLATE/CSS,
|
// Boot — MUST run last: init() -> buildUI() reads TEMPLATE/CSS,
|
||||||
// which are const (TDZ) and only initialized above. At
|
// which are const (TDZ) and only initialized above. At
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Minimal browser stub
|
||||||
|
//
|
||||||
|
// manga-bookmark.user.js is a browser IIFE. At module scope it touches
|
||||||
|
// localStorage (loadCache, loadQueue, the key-migration IIFE) and, on its very
|
||||||
|
// last lines, document. Nothing else runs under test: document.body is left
|
||||||
|
// UNDEFINED, so the boot block takes its `else` branch and waits for a
|
||||||
|
// DOMContentLoaded that never fires. buildUI, init, and every network call stay
|
||||||
|
// dormant with no further stubbing.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
||||||
|
// location stub. The IIFE reads location.href during boot; set it to a dummy value.
|
||||||
|
globalThis.location = {
|
||||||
|
href: "about:blank",
|
||||||
|
hostname: "",
|
||||||
|
pathname: "/",
|
||||||
|
origin: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// og: meta tags the adapters read through meta(). Reassigned per test.
|
||||||
|
let metaTags = {};
|
||||||
|
globalThis.document = {
|
||||||
|
querySelector(sel) {
|
||||||
|
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const v = metaTags[m[1]];
|
||||||
|
return v == null ? null : { getAttribute: () => v };
|
||||||
|
},
|
||||||
|
addEventListener() {},
|
||||||
|
body: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
stripBuildHash,
|
||||||
|
asura,
|
||||||
|
demonic,
|
||||||
|
anchorsFromHTML,
|
||||||
|
statusOf,
|
||||||
|
} = require("../manga-bookmark.user.js");
|
||||||
|
|
||||||
|
// detect() reads only these four properties off location.
|
||||||
|
function loc(href) {
|
||||||
|
const u = new URL(href);
|
||||||
|
return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// stripBuildHash — Asura's slug suffix rotates on every redeploy, so it must
|
||||||
|
// not be part of the series identity. Must stay in sync with asuraBuildHash in
|
||||||
|
// backend/store.go.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
test("stripBuildHash removes a trailing 8-hex suffix", () => {
|
||||||
|
assert.equal(stripBuildHash("solo-leveling-059befe1"), "solo-leveling");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stripBuildHash leaves a slug with no suffix alone", () => {
|
||||||
|
assert.equal(stripBuildHash("solo-leveling"), "solo-leveling");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stripBuildHash ignores suffixes that are not exactly 8 hex chars", () => {
|
||||||
|
assert.equal(stripBuildHash("solo-leveling-abc1234"), "solo-leveling-abc1234");
|
||||||
|
assert.equal(stripBuildHash("solo-leveling-059befe1a"), "solo-leveling-059befe1a");
|
||||||
|
assert.equal(stripBuildHash("solo-leveling-059befeg1"), "solo-leveling-059befeg1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Asura adapter
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
test("asura.detect reads a series page, stripping the hash from the id only", () => {
|
||||||
|
metaTags = { "og:title": "Solo Leveling | Asura Scans", "og:image": "https://cdn.example/x.jpg" };
|
||||||
|
const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1"));
|
||||||
|
assert.equal(p.type, "series");
|
||||||
|
assert.equal(p.site, "asura");
|
||||||
|
assert.equal(p.seriesId, "solo-leveling");
|
||||||
|
// seriesUrl keeps the hash: navigation needs the current one (stale ones 302).
|
||||||
|
assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1");
|
||||||
|
assert.equal(p.title, "Solo Leveling");
|
||||||
|
assert.equal(p.cover, "https://cdn.example/x.jpg");
|
||||||
|
assert.equal(p.chapterNum, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asura.detect reads a chapter page including a decimal number", () => {
|
||||||
|
metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans", "og:image": "" };
|
||||||
|
const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5";
|
||||||
|
const p = asura.detect(loc(url));
|
||||||
|
assert.equal(p.type, "chapter");
|
||||||
|
assert.equal(p.seriesId, "solo-leveling");
|
||||||
|
assert.equal(p.chapterNum, 12.5);
|
||||||
|
assert.equal(p.chapterLabel, "Chapter 12.5");
|
||||||
|
assert.equal(p.chapterUrl, url);
|
||||||
|
assert.equal(p.title, "Solo Leveling");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asura.detect returns other for non-series paths", () => {
|
||||||
|
metaTags = {};
|
||||||
|
assert.equal(asura.detect(loc("https://asurascans.com/")).type, "other");
|
||||||
|
assert.equal(asura.detect(loc("https://asurascans.com/bookmarks")).type, "other");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asura.latestChapterFromAnchors takes the highest and skips the First Chapter shortcut", () => {
|
||||||
|
const best = asura.latestChapterFromAnchors([
|
||||||
|
{ href: "/comics/solo-leveling-059befe1/chapter/1", text: "Chapter 1" },
|
||||||
|
{ href: "/comics/solo-leveling-059befe1/chapter/10", text: "Chapter 10" },
|
||||||
|
{ href: "/comics/solo-leveling-059befe1/chapter/2", text: "Chapter 2" },
|
||||||
|
// Points at a chapter but is not a list entry — the text check excludes it.
|
||||||
|
{ href: "/comics/solo-leveling-059befe1/chapter/1", text: "First Chapter" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(best, { num: 10, label: "Chapter 10" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asura.latestChapterFromAnchors returns null when nothing matches", () => {
|
||||||
|
assert.equal(asura.latestChapterFromAnchors([{ href: "/about", text: "About" }]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Demonic adapter
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
test("demonic.detect reads a series page", () => {
|
||||||
|
metaTags = { "og:title": "The World After The Fall", "og:image": "https://cdn.example/y.jpg" };
|
||||||
|
const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall"));
|
||||||
|
assert.equal(p.type, "series");
|
||||||
|
assert.equal(p.site, "demonic");
|
||||||
|
assert.equal(p.seriesId, "the-world-after-the-fall");
|
||||||
|
assert.equal(p.title, "The World After The Fall");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("demonic.detect reads a chapter page and strips the suffix from the title", () => {
|
||||||
|
metaTags = { "og:title": "The World After The Fall Chapter 3", "og:image": "" };
|
||||||
|
const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1"));
|
||||||
|
assert.equal(p.type, "chapter");
|
||||||
|
assert.equal(p.chapterNum, 3);
|
||||||
|
assert.equal(p.chapterLabel, "Chapter 3");
|
||||||
|
assert.equal(p.seriesUrl, "https://demonicscans.org/manga/the-world-after-the-fall");
|
||||||
|
assert.equal(p.title, "The World After The Fall");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("demonic percent-encoded slugs decode to the same id on /manga/ and /title/", () => {
|
||||||
|
metaTags = {};
|
||||||
|
const series = demonic.detect(loc("https://demonicscans.org/manga/i%2527m-the-max-level-newbie"));
|
||||||
|
const chapter = demonic.detect(loc("https://demonicscans.org/title/i%2527m-the-max-level-newbie/chapter/3/1"));
|
||||||
|
assert.equal(series.seriesId, chapter.seriesId);
|
||||||
|
assert.equal(series.seriesId, "i%27m-the-max-level-newbie");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("demonic.latestChapterFromAnchors parses chaptered.php links, including &", () => {
|
||||||
|
const best = demonic.latestChapterFromAnchors([
|
||||||
|
{ href: "chaptered.php?manga=5&chapter=3", text: "Chapter 3" },
|
||||||
|
{ href: "chaptered.php?manga=5&chapter=12", text: "Chapter 12" },
|
||||||
|
{ href: "/manga/something", text: "Series" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(best, { num: 12, label: "Chapter 12" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Shared helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
test("anchorsFromHTML pulls href/text pairs and flattens nested tags", () => {
|
||||||
|
const out = anchorsFromHTML('<a href="/a">One</a><a class="x" href="/b"><span>Two</span> more</a>');
|
||||||
|
assert.equal(out.length, 2);
|
||||||
|
assert.deepEqual(out[0], { href: "/a", text: "One" });
|
||||||
|
assert.equal(out[1].href, "/b");
|
||||||
|
assert.equal(out[1].text.replace(/\s+/g, " ").trim(), "Two more");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("statusOf defaults a missing status to reading", () => {
|
||||||
|
assert.equal(statusOf({}), "reading");
|
||||||
|
assert.equal(statusOf({ status: "" }), "reading");
|
||||||
|
assert.equal(statusOf({ status: "archived" }), "archived");
|
||||||
|
assert.equal(statusOf({ status: "finished" }), "finished");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user