Files

11 KiB
Raw Permalink Blame History

书签系统(备注+跳转)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)

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 追加(文件末尾)
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:

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