From e117ad52d3d8bf9e11e39d5835c6fa4b05183cc6 Mon Sep 17 00:00:00 2001 From: Fendy Date: Tue, 8 Sep 2026 00:59:46 +0800 Subject: [PATCH] =?UTF-8?q?docs(bookmarks):=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=204=20tasks,=20backend=20vertical=20slice=20first,=20?= =?UTF-8?q?shared=20useBookmarks=20panel=20wired=20into=20all=20readers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../superpowers/plans/2026-09-08-bookmarks.md | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-bookmarks.md diff --git a/docs/superpowers/plans/2026-09-08-bookmarks.md b/docs/superpowers/plans/2026-09-08-bookmarks.md new file mode 100644 index 0000000..7509a96 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-bookmarks.md @@ -0,0 +1,212 @@ +# 书签系统(备注+跳转)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 全格式阅读书签:捕获当前 locator+percent、可备注、列表一键跳转、行内改删。 + +**Architecture:** 新 `bookmarks` 表按 `(user_id, library_id, book_path)` 定位(与 progress 同款);REST 四端点 owner-scoped(他人 404);前端 `createProgressSaver` 顺带记录当前值供 `capture()`,共享组件 `Bookmarks.tsx` 在四个 reader 里以 `{btn, panel}` 两段挂载,seek 复用各 reader 现成的定位函数。 + +**Tech Stack:** Go 1.26 + gin + pgx(backend 容器内跑测试);React + TS + react-query + Tailwind(rd-* 类)。 + +**Spec:** `docs/superpowers/specs/2026-09-08-bookmarks-design.md` + +**Global constraints:** +- 测试执行:`docker exec -w /app booklib-api-1 go test -p 1 -count=1 ./...`(需 DATABASE_URL,容器已配);前端 `docker exec -w /app booklib-web-1 npm run -s check` +- 用户可见变更必须同变更里写 changelog:后端→`docs/CHANGELOG.md`,前端→`docs/CHANGELOG_web.md`,英中相邻两行,不同条目空行隔,新版在上 +- locator 形状不变:cbz/pdf `{page:number}`(0 基),txt/md `{ch:number,scrollFraction:number}`,epub `{cfi:string}` +- note 长度按 rune 计 ≤500 + +--- + +### Task 1: 后端垂直切片(schema+store+handler+router+测试) + +**Files:** +- Modify: `backend/internal/db/schema.sql`(追加表) +- Modify: `backend/internal/store/store.go`(progress 区之后加 Bookmark 方法) +- Create: `backend/cmd/webui/handlers/bookmarks.go` +- Modify: `backend/cmd/webui/api/router.go`(progress 路由旁) +- Test: `backend/cmd/webui/handlers/bookmarks_test.go` + +**Interfaces:** +- Produces: `store.Bookmark{ID,LibraryID,BookPath int64/string...,Locator []byte,Percent float64,Note string,CreatedAt time.Time}`;`InsertBookmark(ctx,userID,libID int64,bookPath string,locator []byte,percent float64,note string)(int64,error)`;`ListBookmarks(ctx,userID,libID int64,bookPath string)([]Bookmark,error)`;`UpdateBookmarkNote(ctx,userID,id int64,note string)(bool,error)`;`DeleteBookmark(ctx,userID,id int64)(bool,error)`;HTTP 四端点见下。 + +- [ ] **Step 1: 写失败测试** `bookmarks_test.go`(复用 setupAPI/adminToken/do/writeCBZ/scanNow/newLibrary/itoa 既有 helper;member 登录用 testPW) + +```go +package handlers_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func TestBookmarkCRUDAndOwnership(t *testing.T) { + st, sc, h, booksDir := setupAPI(t) + tok := adminToken(t, h) + lib, root := newLibrary(t, st, h, tok, booksDir, "bm") + writeCBZ(t, root+"/x.cbz", 5) + scanNow(t, sc, lib) + bs := []map[string]any{} + json.Unmarshal(do(h, "GET", "/api/books?q=x", tok, nil).Body.Bytes(), &bs) + bid := itoa(bs[0]["id"]) + + w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"locator": map[string]int{"page": 3}, "percent": 0.6, "note": "伏笔"}) + if w.Code != 201 { + t.Fatalf("create %d %s", w.Code, w.Body) + } + var bm map[string]any + json.Unmarshal(w.Body.Bytes(), &bm) + bmID := itoa(bm["id"]) + if bm["note"] != "伏笔" { + t.Fatalf("echo: %s", w.Body) + } + // 第二本书 + 第二条书签,列表按 percent 升序且不跨书 + writeCBZ(t, fmt.Sprintf("%s/y.cbz", root), 5) + scanNow(t, sc, lib) + bs = nil + json.Unmarshal(do(h, "GET", "/api/books?q=y", tok, nil).Body.Bytes(), &bs) + yid := itoa(bs[0]["id"]) + do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.2}) + lst := struct{ N int; First float64 }{} + w = do(h, "GET", "/api/books/"+bid+"/bookmarks", tok, nil) + var arr []map[string]any + json.Unmarshal(w.Body.Bytes(), &arr) + if len(arr) != 2 || arr[0]["percent"].(float64) > arr[1]["percent"].(float64) { + t.Fatalf("list order/scope: %s", w.Body) + } + _ = lst + if w := do(h, "GET", "/api/books/"+yid+"/bookmarks", tok, nil); strings.TrimSpace(w.Body.String()) != "[]" { + t.Fatalf("other book leak: %s", w.Body) + } + // 改备注 + w = do(h, "PATCH", "/api/bookmarks/"+bmID, tok, map[string]string{"note": "改了"}) + if w.Code != 200 { + t.Fatalf("patch %d %s", w.Code, w.Body) + } + // 校验:note 超长 / percent 越界 / locator 缺失 + if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.5, "note": strings.Repeat("字", 501)}); w.Code != 400 { + t.Fatalf("long note want 400 got %d", w.Code) + } + if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"locator": map[string]int{"page": 1}, "percent": 1.5}); w.Code != 400 { + t.Fatalf("percent want 400 got %d", w.Code) + } + if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok, + map[string]any{"percent": 0.5}); w.Code != 400 { + t.Fatalf("locator required got %d", w.Code) + } + // 所有权:bob 看不见也改不了 alice 的书签 + do(h, "POST", "/api/users", tok, map[string]string{"username": "carl", "password": testPW, "role": "member"}) + w = do(h, "POST", "/api/auth/login", "", map[string]string{"username": "carl", "password": testPW}) + var lr map[string]string + json.Unmarshal(w.Body.Bytes(), &lr) + bt := lr["token"] + if w := do(h, "GET", "/api/books/"+bid+"/bookmarks", bt, nil); strings.TrimSpace(w.Body.String()) != "[]" { + t.Fatalf("cross-user leak: %s", w.Body) + } + if w := do(h, "PATCH", "/api/bookmarks/"+bmID, bt, map[string]string{"note": "抢"}); w.Code != 404 { + t.Fatalf("cross-user patch want 404 got %d", w.Code) + } + if w := do(h, "DELETE", "/api/bookmarks/"+bmID, bt, nil); w.Code != 404 { + t.Fatalf("cross-user delete want 404 got %d", w.Code) + } + // 本人删除 → 204,再删 404 + if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 204 { + t.Fatalf("delete %d", w.Code) + } + if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 404 { + t.Fatalf("re-delete want 404 got %d", w.Code) + } +} +``` + +- [ ] **Step 2: 跑测试确认失败(404/编译错)** + +Run: `docker exec -w /app booklib-api-1 go test ./cmd/webui/handlers/ -run TestBookmark -count=1` +Expected: FAIL(路由不存在) + +- [ ] **Step 3: schema.sql 追加**(文件末尾) + +```sql +CREATE TABLE IF NOT EXISTS bookmarks ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + library_id BIGINT NOT NULL, + book_path TEXT NOT NULL, + locator JSONB NOT NULL, + percent DOUBLE PRECISION NOT NULL DEFAULT 0, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now()); +CREATE INDEX IF NOT EXISTS bookmarks_user_book_idx ON bookmarks (user_id, library_id, book_path); +``` + +- [ ] **Step 4: store.go 四个方法 + handlers/bookmarks.go + router 四条**(签名见 Interfaces;JSON 键:`id,library_id,path,locator,percent,note,created_at`(RFC3339);PATCH 返回 200 `{id,note}`;book 解析用 `h.bookFromParam`;`uid(c)` 取用户;校验:percent∈[0,1]、`json.Valid(locator)` 且非空、`utf8.RuneCountInString(note)<=500`) + +- [ ] **Step 5: 跑测试转绿 + 全包回归 vet** + +Run: `docker exec -w /app booklib-api-1 go test ./cmd/webui/handlers/ -run TestBookmark -count=1 && docker exec -w /app booklib-api-1 go vet ./...` + +- [ ] **Step 6: Commit** `feat(bookmarks): api+store — per-user CRUD, owner-scoped 404, percent-ordered list` + +--- + +### Task 2: 前端管道(saver.capture + client API + types) + +**Files:** +- Modify: `frontend/src/lib/progress.ts`、`frontend/src/lib/useProgress.ts`、`frontend/src/api/client.ts`、`frontend/src/api/types.ts`、`frontend/src/pages/Reader.tsx` + +**Interfaces:** +- Consumes: Task 1 的 HTTP 契约 +- Produces: `ProgressSaver.at(): {loc: Record, pct: number} | null`;`useProgressSaver(bookId, seed?)` 返回 `ProgressSaver & { capture(): { locator: Record; percent: number } }`;`ReaderProps.initialPercent?: number`;`api.listBookmarks/createBookmark/patchBookmark/deleteBookmark`;`types.Bookmark` + +- [ ] **Step 1:** progress.ts 的 `createProgressSaver` 里加 `let cur`,report 时赋值,返回对象加 `at: () => cur` +- [ ] **Step 2:** useProgress.ts:`ReaderProps` 加 `initialPercent?: number`;`useProgressSaver(bookId, seed?)` 包一层 `capture = () => saver.at() ?? { locator: seed?.locator ?? {}, percent: seed?.percent ?? 0 }`(at 优先,未动过页时回退到打开时的进度) +- [ ] **Step 3:** client.ts 四方法(路径/体按 Task 1 契约);types.ts: + +```ts +export interface Bookmark { + id: number; + locator: Record; + percent: number; + note: string; + created_at: string; +} +``` + +- [ ] **Step 4:** Reader.tsx 渲染 `` 处传 `initialPercent={row?.percent}` +- [ ] **Step 5:** `docker exec -w /app booklib-web-1 npm run -s check` 绿 +- [ ] **Step 6:** Commit `feat(bookmarks): client plumbing — saver capture + bookmark api` + +--- + +### Task 3: 共享组件 + 四 reader 接线 + +**Files:** +- Create: `frontend/src/components/Bookmarks.tsx` +- Modify: `frontend/src/readers/CbzReader.tsx`(工具条+根,复用 toc 侧栏样式)、`TextReader.tsx`(TxtView+MdView 的 ReaderSheet children)、`PdfReader.tsx`(底部按钮排)、`EpubReader.tsx`(底部按钮排,根 div 加 relative) + +**Interfaces:** +- Consumes: Task 2 的 `capture/api/types` +- Produces: `useBookmarks({ book, capture, seek }): { btn: ReactNode; panel: ReactNode }`(hook 命名过 react-rules)— btn 塞工具条,panel 塞 relative 根容器末尾;内部含面板开合/表单状态与查询 + +- [ ] **Step 1: 写 Bookmarks.tsx**(要点:`useQuery(["bookmarks",book.id])`;添加=btn 点开小表单(input+确定/取消,空 note 合法)→ POST→invalidate;panel=目录同款(fixed 遮罩 + absolute 左侧 nav,`aria-label="书签"`):行按钮 `{Math.round(percent*100)}% {note||无备注} {日期}`,点击 `seek(locator)`+收起;✎ 行内编辑 note→PATCH;✕→DELETE→invalidate;toast 报错) +- [ ] **Step 2: CbzReader**:`const bm = Bookmarks({book, capture: saver.capture, seek:(l)=>jump(Number(l.page)||0)})`(capture 需 useProgressSaver 已带 seed:`useProgressSaver(book.id, {locator: initialLocator, percent: initialPercent})`);btn 进底部按钮行,panel 进根 `` 前 +- [ ] **Step 3: TextReader 两个 View**:`seek` 用既有 `goChapter(Number(l.ch))`+设 `scrollFraction`(照抄 initialLocator 恢复段逻辑,抽成函数复用);ReaderSheet children 放 btn,根放 panel;MdView 同(仅 scrollFraction seek) +- [ ] **Step 4: PdfReader**:`seek:(l)=>{const p=Number(l.page); if(p>=0&&p` 排;根容器加 `relative`,panel 进末尾 +- [ ] **Step 5: EpubReader**:`seek:(l)=>rendRef.current?.display(String(l.cfi))`(与初始恢复同款 then/catch 容错,坏 cfi 提示 toast);btn 进上一页/下一页排;根 grid 容器 relative +- [ ] **Step 6:** `npm run -s check` + `npm run -s build` 绿;`vitest`(若存在)不红 +- [ ] **Step 7:** Commit `feat(bookmarks): shared Bookmarks panel + wired into all four readers` + +--- + +### Task 4: 收尾(回归 + 真实点验 + changelog) + +- [ ] **Step 1:** 全量:`go vet ./...` + `go test -p 1 -count=1 ./...`(容器内);`npm run -s check` +- [ ] **Step 2: 环境自愈:** 测试清库后 `DELETE FROM users;...` → 重启 api seed admin → 重建 tv 库 → 浏览器手动点验:cbz 加书签(带备注)→ 翻页 → 打开书签列表点回跳;txt 跨章书签;pdf 页码书签 +- [ ] **Step 3: changelog:** `docs/CHANGELOG.md` Added(API 书签四端点一句 + 中文);`docs/CHANGELOG_web.md` Added(阅读器书签面板 英/中) +- [ ] **Step 4:** Commit `chore(bookmarks): changelog + e2e verification`