Files
mangaBookmark/plans/2026-07-25-bookmark-implementation-plan.md
T
sulthan 053355d0c6 docs: add implementation plan for bookmark reorder/latest-chapter/favorites
Concrete backend + userscript plan against the approved design doc, including
the Store.Upsert return-value fix needed to keep ordering correct once
updated_at becomes conditional, and background-refresh triggering on both
init() and SPA navigation per user preference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 12:01:10 +07:00

14 KiB

Implementation plan: bookmark reorder, latest-chapter tracking, favorites

Context

Design already approved and committed at plans/2026-07-25-bookmark-list-favorites-design.md on branch feat/bookmark-list-favorites-latest. It covers three requested userscript features plus one incidental bug found while verifying feasibility live via Playwright:

  1. Bookmark list reordered so the most-recently-progressed manga is first.
  2. Show the latest available chapter for a manga, not just the last one read — including a background same-origin refresh mechanism to get closer to "live" without server-side polling (Cloudflare blocks that; confirmed live, and confirmed no JSON API / RSS exists on either site to poll instead).
  3. A favorites mechanism (star toggle + tabs) that doesn't remove a manga from the normal list.
  4. asuracomic.net deep links now 301-redirect straight to the asurascans.com homepage (path discarded) — a Cloudflare-edge redirect confirmed live, with no client-side fix possible. Doc-only correction.

This plan turns that design into concrete code changes against the actual current backend (Go/SQLite) and userscript, informed by full reads of backend/store.go, backend/handlers.go, backend/store_test.go, backend/main.go, and the full 782-line userscript/manga-bookmark.user.js.

Key design decision surfaced during planning

Today handlers.go's put() echoes back the client's decoded request struct as the API response, not what was actually persisted. Once updated_at is sometimes not bumped (this whole feature's core mechanic), echoing the request struct back would return a wrong updated_at to the caller on every no-bump write — silently breaking the ordering guarantee the entire feature depends on, since the userscript's syncUpsert/mutation helpers adopt whatever the server echoes back (upsertLocal(saved)) as the new source of truth. Store.Upsert must therefore return the row as actually written (read back inside the same transaction), and handlers.go must respond with that, not the client's payload. This is a correctness fix required by the design, not a new decision to re-litigate.

Confirmed via the user: background latest-chapter refresh should fire on Asura's SPA in-app navigation too (onNavigate()), not only true browser page loads (init()) — more refresh opportunities on a client-routed site that rarely does full reloads, still bounded by the same throttle/batch limits.

Phase 1 — Backend (backend/), TDD

1.1 Tests first — backend/store_test.go

Add four tests (all go through the existing newTestServer(t) / httptest pattern already used in this file, since there are no direct Store-level unit tests in the current style):

  • TestUpsertConditionalUpdatedAt — table-driven: new bookmark (bumps), unchanged progress (no bump), changed progress (bumps), favorite-only change (no bump), latest-chapter-only change (no bump). Assert on the updated_at returned by each PUT response.
  • TestFavoriteRoundTrip — PUT favorite: true, GET list, assert it round-trips.
  • TestLatestChapterNullable — PUT without latest_chapter_num, assert JSON response has "latest_chapter_num":null; PUT again with a value, assert it round-trips.
  • TestOpenStoreMigratesLegacySchema — hand-create the old (10-column) schema in a temp DB file, seed one row, then call OpenStore on it and assert the row survives with the new columns defaulting cleanly (favorite=false, latest_chapter="", latest_chapter_num=nil). This is the safety net for the already-deployed production DB.

Run cd backend && go test ./... — expect compile failures (red state is correct/expected before 1.2).

1.2 backend/store.go

  • Bookmark struct: add Favorite bool json:"favorite", `LatestChapter string `json:"latest_chapter", LatestChapterNum *float64 json:"latest_chapter_num"`` (nullable — only this one needs to be a pointer, per the design doc's data-model table).
  • schema: extend CREATE TABLE IF NOT EXISTS with favorite INTEGER NOT NULL DEFAULT 0, latest_chapter TEXT NOT NULL DEFAULT '', latest_chapter_num REAL (covers fresh installs only).
  • Idempotent migration for the already-deployed DB: add a migrateColumns(db) helper using PRAGMA table_info(bookmarks) to check each new column's existence before running its ALTER TABLE ... ADD COLUMN (SQLite has no ADD COLUMN IF NOT EXISTS). Call it in OpenStore right after the existing schema exec succeeds, same error-wrapping style as today.
  • Shared scanBookmark helper: centralizes converting the favorite INTEGER (0/1) to bool and the nullable latest_chapter_num REAL to *float64 via sql.NullFloat64, used by both List() and Upsert()'s read-back.
  • List(): extend the SELECT to the new columns, scan via scanBookmark.
  • Upsert(b Bookmark) (Bookmark, error) — signature changes to return the stored row. Implementation: wrap in db.Begin()/tx.Commit() (explicit "read exactly what I just wrote" guarantee rather than relying on SetMaxOpenConns(1) staying 1 forever). The INSERT ... ON CONFLICT DO UPDATE SET gets a CASE expression for updated_at:
    updated_at = CASE
      WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
      THEN excluded.updated_at
      ELSE bookmarks.updated_at
    END
    
    This is valid SQLite upsert syntax (bare column = pre-update row value, excluded.col = proposed new row) and naturally handles "new row" for free — ON CONFLICT DO UPDATE only fires on the update path, so a genuinely new row goes through the plain INSERT ... VALUES and always gets the fresh updated_at. After the exec, SELECT the row back inside the same transaction and return it via scanBookmark.

1.3 backend/handlers.go

In put(): keep b.UpdatedAt = time.Now().UnixMilli() as a candidate value (update its comment — it's no longer unconditionally authoritative), then:

stored, err := h.store.Upsert(b)
...
writeJSON(w, http.StatusOK, stored)

No other changes — Favorite/LatestChapter/LatestChapterNum already flow through untouched from the decoded body, which is correct (they're fully client-set synced fields). list()/delete() unchanged.

1.4 Green + build

cd backend && go test ./...          # all pass, including pre-existing TestBookmarkRoundTrip unmodified
cd backend && CGO_ENABLED=0 go build  # static binary still builds

Phase 2 — Userscript (userscript/manga-bookmark.user.js)

No JS test harness in this repo — verification is manual (Phase 4).

  1. Config constants (near CACHE_KEY, ~line 24): LASTCHECKED_KEY = "mangabm:lastchecked", LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000 (4h), LATEST_CHECK_BATCH = 1.

  2. Shared anchor extraction so the exact same per-site chapter-matching rule runs against both the live DOM and raw fetched HTML text (no HTML parser available for the fetch path): anchorsFromDocument(doc) (via querySelectorAll("a[href]")) and anchorsFromHTML(html) (regex-based <a href="...">...</a> extraction). Add near meta() (~line 34).

  3. Per-adapter latestChapterFromAnchors(anchors) added to both asura and demonic adapter objects, implementing the regex rules from the design doc (Asura: href matches /chapter/([\d.]+)$/ AND text matches /Chapter\s+[\d.]+/i, excluding the "First Chapter" quick-jump button; Demonic: all chaptered.php?manga=\d+&chapter=([\d.]+) matches, take max — no order assumption). Plus a computeLatestChapter(site, anchors) dispatcher near keyOf().

  4. mangabm:lastchecked local helpers: loadLastChecked() / saveLastChecked(map), parallel to existing loadCache/saveCache (~line 154), storing { [bookmarkKey]: timestampMs }. Client-local only, never synced.

  5. applyLatestChapterIfChanged(existing, latest) (~near syncUpsert, line 295): if latest.num differs from the bookmark's stored latest_chapter_num, optimistically update local cache + render, then apiPut with updated_at: Date.now() as a candidate — the backend (Phase 1) decides whether to actually apply it, and the client adopts whatever comes back via upsertLocal(saved), same pattern the rest of the file already uses. No client-side "don't reorder" logic needed beyond that — the server is the single source of truth for it. Silent on failure (no toast), per the design doc.

  6. Live-page capture: maybeCaptureLatestOnSeriesPage() — on a type: "series" page for an already-bookmarked series, scan the live DOM via anchorsFromDocument + computeLatestChapter, then applyLatestChapterIfChanged. Hooked into onNavigate() (~line 633), after the existing maybeAutoUpdate() call.

  7. Background opportunistic refresh: backgroundRefreshLatest() — get currentSite() (which adapter matches window.location), pick same-site bookmarks not checked within LATEST_CHECK_THROTTLE_MS (oldest-checked-first), fetch+parse at most LATEST_CHECK_BATCH of them via fetch(bm.series_url).then(r => r.text()) → anchorsFromHTML → computeLatestChapter → applyLatestChapterIfChanged. Mark each attempted bookmark's lastchecked timestamp regardless of success/failure (advances the throttle window either way, avoiding hammering a consistently-failing fetch). Silent on failure. Hook into both init() and onNavigate() (per user's confirmed preference — more refresh opportunities on Asura's SPA navigation, same throttle/batch caps prevent request bursts either way).

  8. toggleFavorite(key) (~near setChapterManual, line 293): flips favorite, optimistic update, apiPut with Date.now() candidate timestamp (again, backend decides), toast on success/failure (consistent with other explicit user-initiated actions like bookmark/remove).

  9. Tabs: new module state let activeTab = "all"; (~near panelOpen, line 374; not persisted, defaults to "all"). Wire click handlers in buildUI() for new #tabAll/#tabFav elements. In render() (~line 568), toggle each tab's .active class and filter which array feeds the list-building loop (activeTab === "favorites" ? state.list.filter(b => b.favorite) : state.list) — state.list itself is never mutated/filtered, so a favorited manga always still appears in "All".

  10. renderItem(b) (~line 579): add a star toggle button (☆/★, onclick: () => toggleFavorite(b.key)) alongside the existing Continue/Edit/Remove buttons, and change the subtitle line to "Read: " + last_chapter + " · Latest: " + latest_chapter when latest_chapter_num is known and strictly greater than last_chapter_num (avoids showing "Latest: Chapter 12" next to "Read: Chapter 12" when they're numerically equal); otherwise keep today's "<last_chapter> · <site>" text.

  11. TEMPLATE (~line 679): insert a tabs bar (<div id="tabs"><button id="tabAll" class="tab active">All</button> <button id="tabFav" class="tab">★ Favorites</button></div>) between #context and #list.

  12. CSS (~near .ctx-sub/.btn.danger): add .tab/.tab.active and .btn.star/.btn.star.active rules following the existing dark-theme .btn modifier convention (.btn.primary, .btn.small, .btn.danger).

  13. Fix the misleading redirect comment (line 41, in the asura adapter object): replace "asuracomic.net currently 301s to asurascans.com; match both." with an accurate note that the redirect now discards the path (goes straight to the asurascans.com root), happens at the Cloudflare edge before any JS runs, so no client-side fix is possible, and the user should navigate via asurascans.com links directly. No change to the matches() regex itself.

  14. @version: bump 1.1.0 → 1.2.0.

Phase 3 — Documentation

  • CLAUDE.md: update the PUT /bookmarks/{key} endpoint description (currently "upsert, server sets updated_at") to describe the new conditional rule, referencing plans/2026-07-25-bookmark-list-favorites-design.md §4.
  • README.md: update the matching endpoint-table row, and correct the "Adapter reference" section's Asura row to note asuracomic.net deep links currently 301 to the asurascans.com root (broken/path discarded) — use asurascans.com links directly. While touching this, also fix CLAUDE.md's intro line ("asuracomic.net (formerly asurascans.com)"), which has the relationship backwards and is inconsistent with README's own phrasing ("asurascans.com (a.k.a. asuracomic.net)") — bundle this small adjacent correction in since it's directly related to the same finding.

Verification

Backend (automated):

cd backend && go test ./...
cd backend && CGO_ENABLED=0 go build

Backend (manual smoke test, extends the existing curl convention in CLAUDE.md/README.md): PUT a new bookmark, then PUT again changing only favorite, then only latest_chapter*, then a real last_chapter_num advance — confirm via jq .updated_at that only the first and last calls change updated_at.

Userscript (manual — no JS test harness exists in this repo, matching existing project convention):

  • List reorders only on a genuine progress advance, not on plain re-visit, favorite toggle, or latest-chapter capture.
  • Latest-chapter capture fires when visiting a bookmarked series page on both sites and displays the "Read: X · Latest: Y" subtitle correctly.
  • Background refresh: check localStorage['mangabm:lastchecked'] in devtools to confirm throttling behavior; confirm via the Network tab that it never fires a cross-site request (only same-origin as the currently loaded site).
  • Favorite toggle persists across a panel close/reopen and a refresh() round-trip through the backend; favorited manga still shows in "All".
  • Fastest iteration path: desktop Tampermonkey/Violentmonkey first (script stays GM_*-free), then confirm on Bromite per existing project convention.

Critical files

  • backend/store.go
  • backend/handlers.go
  • backend/store_test.go
  • userscript/manga-bookmark.user.js
  • CLAUDE.md
  • README.md