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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user