4229c179b0
Two intertwined changes — the rebrand and the novel library were developed on
the same branch because the novel UI plumbing is part of the new "Bookmark
Manager" wordmark in the web shell.
## What it does
- **Rebrand**: MangaBM → BookmarkManager across the Go module, compose stack,
env vars, Traefik hostnames, container/image names, userscript storage
prefixes (`mangabm:cache` → `bmgr:manga:cache`, `mangabm:queue` → `bmgr:manga:queue`),
and docs.
- **Novel library**: same backend, two libraries. New `kind` column splits
bookmarks into `manga` / `novel`; PUT validates it. Two userscripts:
- `manga-bookmark.user.js` — unchanged behaviour, just stamps its own `kind`.
- `novel-bookmark.user.js` — separate Violentmonkey install with adapters
for **novelfull.com** (polled via headless browser — Cloudflare JS
challenge) and **lightnovelworld.net** (polled via plain TLS).
- **Web UI**: library switch on the app shell. Login art, libswitch, and
novel-site colours from the Cinder design snapshot.
## Plumbing
- `addedColumns` ALTER for `kind` runs on first start after upgrade; every
pre-existing row is backfilled to `'manga'`. No manual SQL, no down-time.
- `ALLOWED_ORIGINS` gains the two novel sites.
- New `NOVEL_USERSCRIPT_PATH` env (default `/userscript/novel-bookmark.user.js`),
bindmounted alongside the manga script.
- Traefik router names `mangabm*` → `bmapi*` / `bmweb*`.
## Test status
- `go test ./...` — green
- `node --test userscript/test/logic.test.js` — 34 pass
- `node --test userscript/test/novel-logic.test.js` — 11 pass
- `node --check` on both userscripts — clean
## Notes for the redeploy
.env keys were renamed (`MANGA_API_HOST` → `BOOKMARK_API_HOST`,
`MANGA_WEB_HOST` → `BOOKMARK_WEB_HOST`). Update DNS / Traefik labels on the
prod override before pulling, otherwise the public hostnames go dark.
See the redeploy instructions I'll post next to this PR.
Reviewed-on: #15
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
395 lines
16 KiB
JavaScript
395 lines
16 KiB
JavaScript
"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 = {};
|
|
// img[alt] elements comix's coverFromPage() scans. Reassigned per test; each
|
|
// entry is {alt, src}.
|
|
let pageImages = [];
|
|
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 };
|
|
},
|
|
querySelectorAll(sel) {
|
|
if (sel !== "img[alt]") return [];
|
|
return pageImages.map((img) => ({
|
|
getAttribute: (attr) => img[attr] ?? null,
|
|
}));
|
|
},
|
|
addEventListener() {},
|
|
body: undefined,
|
|
};
|
|
|
|
const {
|
|
stripBuildHash,
|
|
comixSeriesId,
|
|
asura,
|
|
demonic,
|
|
comix,
|
|
kagane,
|
|
anchorsFromHTML,
|
|
statusOf,
|
|
kindOf,
|
|
} = 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" });
|
|
});
|
|
|
|
// ============================================================
|
|
// comix — the id prefix is the stable identity; the slug tail is a title
|
|
// rendering that changes when a series is renamed.
|
|
// ============================================================
|
|
|
|
test("comixSeriesId keeps only the prefix before the first dash", () => {
|
|
assert.equal(comixSeriesId("n8we-dungeons-and-crayons"), "n8we");
|
|
assert.equal(comixSeriesId("20xzd-the-baddest-villainess-is-back"), "20xzd");
|
|
});
|
|
|
|
test("comixSeriesId leaves a bare id untouched", () => {
|
|
assert.equal(comixSeriesId("n8we"), "n8we");
|
|
});
|
|
|
|
test("comix detects a series page", () => {
|
|
metaTags = { "og:title": "Dungeons and Crayons" };
|
|
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
|
|
assert.equal(p.type, "series");
|
|
assert.equal(p.site, "comix");
|
|
assert.equal(p.seriesId, "n8we");
|
|
assert.equal(p.title, "Dungeons and Crayons");
|
|
assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons");
|
|
assert.equal(p.chapterNum, null);
|
|
});
|
|
|
|
test("comix detects a chapter page and strips the Ch. suffix from the title", () => {
|
|
metaTags = { "og:title": "Dungeons and Crayons · Ch.80" };
|
|
const p = comix.detect(
|
|
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80")
|
|
);
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "n8we");
|
|
assert.equal(p.title, "Dungeons and Crayons");
|
|
assert.equal(p.chapterNum, 80);
|
|
assert.equal(p.chapterLabel, "Chapter 80");
|
|
assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons");
|
|
});
|
|
|
|
test("comix parses decimal chapter numbers", () => {
|
|
metaTags = { "og:title": "Dungeons and Crayons · Ch.80.5" };
|
|
const p = comix.detect(
|
|
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80.5")
|
|
);
|
|
assert.equal(p.chapterNum, 80.5);
|
|
});
|
|
|
|
test("comix.detect reads the cover from an img whose alt matches the cleaned title", () => {
|
|
metaTags = { "og:title": "Dungeons and Crayons · Ch.80" };
|
|
pageImages = [
|
|
{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" },
|
|
{ alt: "Dungeons and Crayons", src: "https://cdn.example/cover.jpg" },
|
|
];
|
|
const p = comix.detect(
|
|
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80")
|
|
);
|
|
assert.equal(p.cover, "https://cdn.example/cover.jpg");
|
|
});
|
|
|
|
test("comix.detect leaves cover empty when no img alt matches the title", () => {
|
|
metaTags = { "og:title": "Dungeons and Crayons" };
|
|
pageImages = [{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" }];
|
|
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
|
|
assert.equal(p.cover, "");
|
|
pageImages = [];
|
|
});
|
|
|
|
test("comix ignores unrelated paths", () => {
|
|
assert.equal(comix.detect(loc("https://comix.to/browse")).type, "other");
|
|
});
|
|
|
|
test("comix takes the max chapter and ignores other series' links", () => {
|
|
const anchors = anchorsFromHTML(`
|
|
<a href="/title/n8we-dungeons-and-crayons/11123327-chapter-79">Chapter 79</a>
|
|
<a href="/title/n8we-dungeons-and-crayons/11139891-chapter-80">Chapter 80</a>
|
|
<a href="/title/n8we-dungeons-and-crayons/10794753-chapter-78">Chapter 78</a>
|
|
<a href="/title/qqwrm-full-time-awakening/99999999-chapter-999">Chapter 999</a>
|
|
`);
|
|
assert.deepEqual(comix.latestChapterFromAnchors(anchors, "n8we"), {
|
|
num: 80,
|
|
label: "Chapter 80",
|
|
});
|
|
});
|
|
|
|
test("comix latest returns null when no chapter links are present", () => {
|
|
assert.equal(comix.latestChapterFromAnchors(anchorsFromHTML("<a href='/browse'>x</a>"), "n8we"), null);
|
|
});
|
|
|
|
test("asura and demonic ignore the seriesId argument", () => {
|
|
const asuraAnchors = anchorsFromHTML(
|
|
`<a href="/comics/x-aabbccdd/chapter/12"><span>Chapter 12</span></a>`
|
|
);
|
|
assert.deepEqual(asura.latestChapterFromAnchors(asuraAnchors, "ignored"), {
|
|
num: 12,
|
|
label: "Chapter 12",
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// kagane — reader URLs carry uuids and no chapter number, so the number has to
|
|
// come out of og:title. When that fails, chapterNum is null and the existing
|
|
// progress logic records the current chapter rather than guessing.
|
|
// ============================================================
|
|
|
|
const KAGANE_SERIES = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b";
|
|
const KAGANE_BOOK = "019fa2e0-6dbd-73ca-b40b-fe06ab75eb0e";
|
|
|
|
test("kagane detects a series page", () => {
|
|
metaTags = {
|
|
"og:title": "Infinite Decryption: The Strongest Level 0",
|
|
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
|
|
};
|
|
const p = kagane.detect(loc("https://kagane.to/series/" + KAGANE_SERIES));
|
|
assert.equal(p.type, "series");
|
|
assert.equal(p.site, "kagane");
|
|
assert.equal(p.seriesId, KAGANE_SERIES);
|
|
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
|
|
assert.equal(p.cover, "https://kagane.to/api/v2/image/abc/compressed");
|
|
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
|
|
});
|
|
|
|
test("kagane reads the chapter number out of og:title", () => {
|
|
metaTags = {
|
|
"og:title": "Infinite Decryption: The Strongest Level 0 - Chapter 41 - Episode 41",
|
|
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
|
|
};
|
|
const p = kagane.detect(
|
|
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
|
|
);
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, KAGANE_SERIES);
|
|
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
|
|
assert.equal(p.chapterNum, 41);
|
|
assert.equal(p.chapterLabel, "Chapter 41");
|
|
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
|
|
});
|
|
|
|
test("kagane yields a null chapterNum when og:title has no chapter", () => {
|
|
metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" };
|
|
const p = kagane.detect(
|
|
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
|
|
);
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.chapterNum, null);
|
|
});
|
|
|
|
test("kagane ignores unrelated paths", () => {
|
|
assert.equal(kagane.detect(loc("https://kagane.to/search")).type, "other");
|
|
});
|
|
|
|
test("kagane anchor scanning is structurally impossible and returns null", () => {
|
|
const anchors = anchorsFromHTML(
|
|
`<a href="/series/${KAGANE_SERIES}/reader/${KAGANE_BOOK}">Chapter 41</a>`
|
|
);
|
|
assert.equal(kagane.latestChapterFromAnchors(anchors, KAGANE_SERIES), null);
|
|
});
|
|
|
|
test("kagane latestChapterFromApi takes the max chapter_no", async () => {
|
|
globalThis.fetch = async (url) => {
|
|
assert.equal(url, "/api/v2/series/" + KAGANE_SERIES);
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
series_books: [
|
|
{ chapter_no: "1", title: "Episode 1" },
|
|
{ chapter_no: "41", title: "Episode 41" },
|
|
{ chapter_no: "40.5", title: "Episode 40.5" },
|
|
],
|
|
}),
|
|
};
|
|
};
|
|
assert.deepEqual(await kagane.latestChapterFromApi(KAGANE_SERIES), {
|
|
num: 41,
|
|
label: "Chapter 41",
|
|
});
|
|
});
|
|
|
|
test("kagane latestChapterFromApi returns null on a challenge or error", async () => {
|
|
globalThis.fetch = async () => ({ ok: false, status: 403 });
|
|
assert.equal(await kagane.latestChapterFromApi(KAGANE_SERIES), null);
|
|
});
|
|
|
|
// ============================================================
|
|
// 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");
|
|
});
|
|
|
|
// ============================================================
|
|
// kindOf — a row written before the kind column existed has none, and every
|
|
// one of those is manga.
|
|
// ============================================================
|
|
|
|
test("kindOf defaults a missing kind to manga", () => {
|
|
assert.equal(kindOf({}), "manga");
|
|
assert.equal(kindOf({ kind: "" }), "manga");
|
|
});
|
|
|
|
test("kindOf passes through an explicit kind", () => {
|
|
assert.equal(kindOf({ kind: "novel" }), "novel");
|
|
});
|