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
+73 -8
View File
@@ -14,8 +14,8 @@ import (
"strings"
"time"
"mangabm/backend/internal/session"
"mangabm/backend/internal/store"
"bookmarkmanager/backend/internal/session"
"bookmarkmanager/backend/internal/store"
)
//go:embed templates
@@ -40,6 +40,10 @@ type Handler struct {
// listView is what every list-rendering template receives.
type listView struct {
// Lib is the library this view renders: store.KindManga or store.KindNovel.
// Manga is the default and carries no query parameter, so every pre-novel
// URL keeps meaning exactly what it did.
Lib string
Tab string // "all", "fav", or "new"
Recent []store.Bookmark
Items []store.Bookmark
@@ -53,6 +57,23 @@ type listView struct {
OOB bool
}
// PageURL and ListURL are the two link shapes every tab needs. Building them
// here rather than concatenating in the template is what keeps the library
// parameter from being dropped on one link out of ten.
func (v listView) PageURL(tab string) string {
if v.Lib == store.KindNovel {
return "/?lib=novel&tab=" + tab
}
return "/?tab=" + tab
}
func (v listView) ListURL(tab string) string {
if v.Lib == store.KindNovel {
return "/ui/list?lib=novel&tab=" + tab
}
return "/ui/list?tab=" + tab
}
// loginView is what the login template receives.
type loginView struct {
Error string
@@ -146,7 +167,7 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "login", loginView{})
return
}
view, err := h.buildListView(r.URL.Query().Get("tab"))
view, err := h.buildListView(libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
if err != nil {
log.Printf("index: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -167,6 +188,26 @@ func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []sto
return out
}
// kindOf reads a bookmark's library. A row cached or written before the kind
// column existed has none; every one of those is manga, which is what the
// column default says too.
func kindOf(b store.Bookmark) string {
if b.Kind == "" {
return store.KindManga
}
return b.Kind
}
// libOf normalises the query parameter. Anything that is not the novel library
// is the manga one, so a typo lands on the default page rather than an empty
// list.
func libOf(q string) string {
if q == store.KindNovel {
return store.KindNovel
}
return store.KindManga
}
// buildListView loads the list once and derives both the tab-filtered items and
// the recent strip from it.
//
@@ -174,11 +215,20 @@ func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []sto
// in All, not in Updated, not in Favourites, and not in the recent strip. An
// archived favourite therefore shows only under Archived: Favourites means
// "favourites I am currently reading".
func (h *Handler) buildListView(tab string) (listView, error) {
func (h *Handler) buildListView(lib, tab string) (listView, error) {
all, err := h.store.List() // already ordered updated_at DESC
if err != nil {
return listView{}, err
}
// Narrow to one library first: reading, withNew and recent all derive from
// this slice, so doing it later would let the other library's rows into the
// strip and the Updated badge.
all = filterBookmarks(all, func(b store.Bookmark) bool { return kindOf(b) == lib })
// Novels do not offer an Updated tab, so a hand-typed one lands on All.
if lib == store.KindNovel && tab == "new" {
tab = "all"
}
reading := filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusReading })
withNew := filterBookmarks(reading, func(b store.Bookmark) bool { return b.HasNewChapter() })
@@ -213,11 +263,11 @@ func (h *Handler) buildListView(tab string) (listView, error) {
recent = recent[:RecentCount]
}
}
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
return listView{Lib: lib, Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
}
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
view, err := h.buildListView(r.URL.Query().Get("tab"))
view, err := h.buildListView(libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
if err != nil {
log.Printf("ui list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -241,6 +291,17 @@ func currentTab(r *http.Request) string {
return u.Query().Get("tab")
}
// currentLib is the library the reader is looking at, read from htmx's own
// header for the same reason currentTab is: out-of-band chrome must be rebuilt
// for that view rather than for the default one.
func currentLib(r *http.Request) string {
u, err := url.Parse(r.Header.Get("HX-Current-URL"))
if err != nil {
return store.KindManga
}
return libOf(u.Query().Get("lib"))
}
// writeChromeOOB appends the regions that live outside #list — the recent
// strip, the Updated badge and the action key — as out-of-band swaps, so a
// mutation cannot leave them describing the library as it was before the tap.
@@ -248,7 +309,11 @@ func currentTab(r *http.Request) string {
// Archive for Restore.
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
view.OOB = true
for _, name := range []string{"recent", "newcount", "keyrow"} {
names := []string{"recent", "keyrow"}
if view.Lib == store.KindManga {
names = append(names, "newcount")
}
for _, name := range names {
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
// The card is already written; stale chrome beats a torn response.
log.Printf("render %s oob: %v", name, err)
@@ -260,7 +325,7 @@ func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
// refreshChrome rebuilds the chrome for the reader's current tab after a
// mutation and appends it to the response.
func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
view, err := h.buildListView(currentTab(r))
view, err := h.buildListView(currentLib(r), currentTab(r))
if err != nil {
log.Printf("ui chrome: %v", err)
return