rebrand: MangaBM → BookmarkManager, add novel library support (#15)

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>
This commit was merged in pull request #15.
This commit is contained in:
2026-08-06 03:58:23 +07:00
committed by sulthan
parent c445762244
commit 4229c179b0
40 changed files with 2948 additions and 347 deletions
+41 -12
View File
@@ -4,8 +4,8 @@
// @version 1.6.0
// @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
// @author you
// @downloadURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
// @updateURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
// @downloadURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
// @updateURL https://bookmark-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
// @match https://asuracomic.net/*
// @match https://asurascans.com/*
// @match https://demonicscans.org/*
@@ -21,19 +21,38 @@
// ============================================================
// CONFIG — fill these in before installing.
// ============================================================
const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
const WEB_BASE = "https://manga.violetcrown.my.id"; // the browser UI, for the panel's nav chips
const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips
// This script owns the manga library; the novel 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:manga:";
// Which library this script's rows belong to. The novel script is a separate
// install that declares "novel"; the backend keeps whichever it is told.
const LIBRARY = "manga";
// Safe in Bromite's isolated world: the page's own JS cannot read these.
const CACHE_KEY = "mangabm:cache";
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 = "mangabm:lastchecked";
const LASTCHECKED_KEY = STORE_PREFIX + "lastchecked";
const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000;
const LATEST_CHECK_BATCH = 1; // series fetched per navigation
// One-time carry-over from the pre-rebrand key names. The cache would rebuild
// itself from the server, but the retry queue would not: dropping it loses
// writes made while offline.
for (const name of ["cache", "lastchecked", "queue", "fabpos"]) {
const old = localStorage.getItem("mangabm:" + name);
if (old !== null && localStorage.getItem(STORE_PREFIX + name) === null) {
localStorage.setItem(STORE_PREFIX + name, old);
}
localStorage.removeItem("mangabm:" + name);
}
// ============================================================
// Site adapters
//
@@ -503,7 +522,9 @@
}
function setList(list) {
state.list = Array.isArray(list) ? 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);
}
@@ -528,19 +549,25 @@
return b.status || "reading";
}
// A list cached by an older version has no kind field, and a row the server
// defaulted has "manga" — both mean the same thing here.
function kindOf(b) {
return b.kind || "manga";
}
// ============================================================
// Retry queue
//
// A failed write is not rolled back and not lost: the key is parked here and
// replayed when the backend is next reachable. Entries carry no payload — the
// body is read from state.byKey at send time, because state.list *is* the
// desired state and is already persisted in mangabm:cache. One entry per key,
// 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 = "mangabm:queue";
const QUEUE_KEY = STORE_PREFIX + "queue";
const QUEUE_MAX = 200; // ~12 KB; realistically bounded by the bookmark count
const QUEUE_MAX_ATTEMPTS = 10;
@@ -812,6 +839,7 @@
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) || "",
@@ -834,6 +862,7 @@
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 || "",
@@ -1075,7 +1104,7 @@
// FAB placement + dragging (snaps to nearest left/right edge)
// ============================================================
const FAB_KEY = "mangabm:fabpos";
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
@@ -1580,7 +1609,7 @@
</g>
</g>
</svg>
<span>manga<em>Bookmark</em></span>
<span>Bookmark<em>Manager</em></span>
</span>
<button id="closeBtn" aria-label="Close">close</button>
</header>
@@ -1784,7 +1813,7 @@
// Exposes pure logic only — see userscript/test/logic.test.js.
// ============================================================
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf };
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf, kindOf };
}
// ============================================================