Files

213 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 书签系统(备注+跳转)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<string,unknown>, pct: number} | null`;`useProgressSaver(bookId, seed?)` 返回 `ProgressSaver & { capture(): { locator: Record<string, unknown>; 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<string, unknown>;
percent: number;
note: string;
created_at: string;
}
```
- [ ] **Step 4:** Reader.tsx 渲染 `<R>` 处传 `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 进根 `</div>` 前
- [ ] **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<num) setPage(p)}`;btn 进底部 `<button>` 排;根容器加 `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`