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>
183 lines
6.7 KiB
JavaScript
183 lines
6.7 KiB
JavaScript
"use strict";
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert");
|
|
|
|
// ============================================================
|
|
// Minimal browser stub. Same shape as logic.test.js, plus a meta[name=...]
|
|
// branch: novelfull ships no og: tags, so its cover comes from name="image".
|
|
// document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded
|
|
// that never fires and no network call is ever made.
|
|
// ============================================================
|
|
|
|
const store = new Map();
|
|
globalThis.localStorage = {
|
|
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
|
setItem: (k, v) => store.set(k, String(v)),
|
|
removeItem: (k) => store.delete(k),
|
|
};
|
|
|
|
globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" };
|
|
|
|
let metaTags = {};
|
|
let namedMetas = {};
|
|
let elements = {};
|
|
globalThis.document = {
|
|
querySelector(sel) {
|
|
let m = sel.match(/^meta\[property="([^"]+)"\]$/);
|
|
if (m) {
|
|
const v = metaTags[m[1]];
|
|
return v == null ? null : { getAttribute: () => v };
|
|
}
|
|
m = sel.match(/^meta\[name="([^"]+)"\]$/);
|
|
if (m) {
|
|
const v = namedMetas[m[1]];
|
|
return v == null ? null : { getAttribute: () => v };
|
|
}
|
|
const text = elements[sel];
|
|
return text == null ? null : { textContent: text };
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
addEventListener() {},
|
|
body: undefined,
|
|
};
|
|
|
|
const {
|
|
novelfull,
|
|
lightnovelworld,
|
|
kindOf,
|
|
maxChapter,
|
|
} = require("../novel-bookmark.user.js");
|
|
|
|
function loc(href) {
|
|
const u = new URL(href);
|
|
return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname };
|
|
}
|
|
|
|
function reset() {
|
|
metaTags = {};
|
|
namedMetas = {};
|
|
elements = {};
|
|
}
|
|
|
|
// ============================================================
|
|
// novelfull adapter
|
|
// ============================================================
|
|
|
|
test("novelfull.detect reads a series page", () => {
|
|
reset();
|
|
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
|
|
elements = { "h3.title": "Reverend Insanity" };
|
|
const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html"));
|
|
assert.equal(p.type, "series");
|
|
assert.equal(p.site, "novelfull");
|
|
assert.equal(p.seriesId, "reverend-insanity");
|
|
assert.equal(p.title, "Reverend Insanity");
|
|
assert.equal(p.cover, "https://novelfull.com/uploads/thumbs/ri.jpg");
|
|
assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html");
|
|
assert.equal(p.chapterNum, null);
|
|
});
|
|
|
|
test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => {
|
|
reset();
|
|
namedMetas = { image: "https://novelfull.com/uploads/thumbs/ri.jpg" };
|
|
elements = { "a.truyen-title": "Reverend Insanity" };
|
|
const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html";
|
|
const p = novelfull.detect(loc(url));
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "reverend-insanity");
|
|
assert.equal(p.chapterNum, 2334);
|
|
assert.equal(p.chapterLabel, "Chapter 2334");
|
|
assert.equal(p.chapterUrl, url);
|
|
assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html");
|
|
assert.equal(p.title, "Reverend Insanity");
|
|
});
|
|
|
|
test("novelfull.detect returns other for non-series paths", () => {
|
|
reset();
|
|
assert.equal(novelfull.detect(loc("https://novelfull.com/")).type, "other");
|
|
assert.equal(novelfull.detect(loc("https://novelfull.com/genre/Fantasy")).type, "other");
|
|
});
|
|
|
|
test("novelfull.latestChapterFromAnchors takes the max and ignores other series", () => {
|
|
const best = novelfull.latestChapterFromAnchors(
|
|
[
|
|
{ href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" },
|
|
{ href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" },
|
|
{ href: "/reverend-insanity/chapter-2.html", text: "Chapter 2" },
|
|
{ href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" },
|
|
],
|
|
"reverend-insanity"
|
|
);
|
|
assert.deepEqual(best, { num: 2334, label: "Chapter 2334" });
|
|
});
|
|
|
|
// ============================================================
|
|
// lightnovelworld adapter
|
|
// ============================================================
|
|
|
|
test("lightnovelworld.detect reads a series page", () => {
|
|
reset();
|
|
metaTags = { "og:image": "https://lightnovelworld.net/wp-content/uploads/awe.webp" };
|
|
elements = { "h1.entry-title": "A Will Eternal" };
|
|
const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/"));
|
|
assert.equal(p.type, "series");
|
|
assert.equal(p.site, "lightnovelworld");
|
|
assert.equal(p.seriesId, "a-will-eternal");
|
|
assert.equal(p.title, "A Will Eternal");
|
|
assert.equal(p.cover, "https://lightnovelworld.net/wp-content/uploads/awe.webp");
|
|
});
|
|
|
|
test("lightnovelworld.detect strips the chapter suffix off the heading", () => {
|
|
reset();
|
|
elements = { "h1.entry-title": "A Will Eternal Chapter 1298" };
|
|
const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/";
|
|
const p = lightnovelworld.detect(loc(url));
|
|
assert.equal(p.type, "chapter");
|
|
assert.equal(p.seriesId, "a-will-eternal");
|
|
assert.equal(p.chapterNum, 1298);
|
|
assert.equal(p.chapterLabel, "Chapter 1298");
|
|
assert.equal(p.title, "A Will Eternal");
|
|
assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/");
|
|
// Chapter pages have no cover; the merge in bookmarkCurrent keeps the stored one.
|
|
assert.equal(p.cover, "");
|
|
});
|
|
|
|
test("lightnovelworld.detect returns other for non-series paths", () => {
|
|
reset();
|
|
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other");
|
|
assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/az-lists/")).type, "other");
|
|
});
|
|
|
|
test("lightnovelworld.latestChapterFromAnchors takes the max and ignores other series", () => {
|
|
const best = lightnovelworld.latestChapterFromAnchors(
|
|
[
|
|
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1/", text: "Chapter 1" },
|
|
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1317/", text: "Chapter 1317" },
|
|
{ href: "https://lightnovelworld.net/a-will-eternal-chapter-1298/", text: "Chapter 1298" },
|
|
{ href: "https://lightnovelworld.net/overgeared-chapter-9999/", text: "Chapter 9999" },
|
|
],
|
|
"a-will-eternal"
|
|
);
|
|
assert.deepEqual(best, { num: 1317, label: "Chapter 1317" });
|
|
});
|
|
|
|
test("latestChapterFromAnchors returns null when nothing matches", () => {
|
|
assert.equal(novelfull.latestChapterFromAnchors([{ href: "/about", text: "About" }], "x"), null);
|
|
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
|
|
});
|
|
|
|
// ============================================================
|
|
// kindOf
|
|
// ============================================================
|
|
|
|
test("kindOf defaults a missing kind to manga", () => {
|
|
assert.equal(kindOf({}), "manga");
|
|
});
|
|
|
|
test("kindOf passes through novel", () => {
|
|
assert.equal(kindOf({ kind: "novel" }), "novel");
|
|
});
|