feat: add EPUB reading notes and highlights

This commit is contained in:
2026-10-26 15:02:36 +08:00
parent 64df54ad55
commit 83dc040e1d
7 changed files with 224 additions and 10 deletions
+69 -3
View File
@@ -9,9 +9,9 @@ from sqlalchemy import select, func
from app.core.database import get_db, async_session
from app.core.config import settings
from app.models.book import Book, Chapter, Segment
from app.models.book import Book, Chapter, Segment, ReadingNote
from app.models.chat import Conversation
from app.schemas.book import BookOut, BookDetailOut, ChapterOut, SegmentOut
from app.schemas.book import BookOut, BookDetailOut, ChapterOut, SegmentOut, ReadingNoteCreate, ReadingNoteOut, ReadingNoteUpdate
from app.epub.parser import parse_epub
from app.rag.vector import ensure_embeddings
@@ -134,6 +134,67 @@ async def get_segments(book_id: str, chapter_id: str | None = None, db: AsyncSes
return result.scalars().all()
@router.get("/{book_id}/notes", response_model=list[ReadingNoteOut])
async def list_reading_notes(book_id: str, db: AsyncSession = Depends(get_db)):
if not await db.get(Book, book_id):
raise HTTPException(404, "书不存在")
result = await db.execute(
select(ReadingNote).where(ReadingNote.book_id == book_id).order_by(ReadingNote.created_at.desc())
)
return result.scalars().all()
@router.post("/{book_id}/notes", response_model=ReadingNoteOut, status_code=201)
async def create_reading_note(book_id: str, payload: ReadingNoteCreate, db: AsyncSession = Depends(get_db)):
if not await db.get(Book, book_id):
raise HTTPException(404, "书不存在")
excerpt = payload.excerpt.strip()
if not excerpt or len(excerpt) > 4000:
raise HTTPException(422, "摘录长度必须为 1-4000 个字符")
if not payload.cfi.strip() or len(payload.cfi) > 2000:
raise HTTPException(422, "阅读位置无效")
if payload.kind not in {"excerpt", "highlight"}:
raise HTTPException(422, "笔记类型无效")
if payload.color not in {"yellow", "green", "blue"}:
raise HTTPException(422, "高亮颜色无效")
if payload.note is not None and len(payload.note) > 2000:
raise HTTPException(422, "笔记长度不能超过 2000 个字符")
note = ReadingNote(book_id=book_id, kind=payload.kind, excerpt=excerpt, note=payload.note,
cfi=payload.cfi, chapter_title=payload.chapter_title, color=payload.color)
db.add(note)
await db.commit()
await db.refresh(note)
return note
@router.patch("/{book_id}/notes/{note_id}", response_model=ReadingNoteOut)
async def update_reading_note(book_id: str, note_id: str, payload: ReadingNoteUpdate, db: AsyncSession = Depends(get_db)):
note = await db.get(ReadingNote, note_id)
if not note or note.book_id != book_id:
raise HTTPException(404, "摘录不存在")
if payload.note is not None:
if len(payload.note) > 2000:
raise HTTPException(422, "笔记长度不能超过 2000 个字符")
note.note = payload.note
if payload.color is not None:
if payload.color not in {"yellow", "green", "blue"}:
raise HTTPException(422, "高亮颜色无效")
note.color = payload.color
await db.commit()
await db.refresh(note)
return note
@router.delete("/{book_id}/notes/{note_id}")
async def delete_reading_note(book_id: str, note_id: str, db: AsyncSession = Depends(get_db)):
note = await db.get(ReadingNote, note_id)
if not note or note.book_id != book_id:
raise HTTPException(404, "摘录不存在")
await db.delete(note)
await db.commit()
return {"deleted": note_id}
@router.delete("/{book_id}")
async def delete_book(book_id: str, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, book_id)
@@ -146,10 +207,15 @@ async def delete_book(book_id: str, db: AsyncSession = Depends(get_db)):
convs = (await db.execute(
select(Conversation).where(Conversation.book_id == book_id)
)).scalars().all()
notes = (await db.execute(
select(ReadingNote).where(ReadingNote.book_id == book_id)
)).scalars().all()
for c in convs:
await db.delete(c)
for note in notes:
await db.delete(note)
await db.delete(book)
await db.commit()
if file_path and os.path.exists(file_path):
os.remove(file_path)
return {"deleted": book_id, "conversations": len(convs)}
return {"deleted": book_id, "conversations": len(convs), "notes": len(notes)}
+3 -1
View File
@@ -27,6 +27,8 @@ async def _migrate():
if rows and "embedding" not in cols:
await conn.exec_driver_sql("ALTER TABLE segments ADD COLUMN embedding TEXT")
# Notes/highlights use their own book-scoped table; create_all creates it for existing DBs.
# conversations:全局「第二大脑」对话需要 book_id 可空 + kind 列(book/assistant)。
# SQLite 无法 ALTER 去掉 NOT NULL,这里整表重建(保留数据,旧会话统一 kind='book');
# 新建库由 create_all 直接按新 ORM schema 建表,走到这里时 kind 已存在 → 跳过。
@@ -65,4 +67,4 @@ async def _migrate():
async def get_db():
async with async_session() as session:
yield session
yield session
+17 -1
View File
@@ -57,4 +57,20 @@ class Segment(Base):
metadata_: Mapped[str | None] = mapped_column("metadata", Text)
embedding: Mapped[str | None] = mapped_column(Text) # JSON 浮点数组(向量检索用);NULL = 尚未生成向量
chapter: Mapped["Chapter"] = relationship(back_populates="segments")
chapter: Mapped["Chapter"] = relationship(back_populates="segments")
class ReadingNote(Base):
"""A user-owned passage, highlight, or note anchored to an EPUB CFI."""
__tablename__ = "reading_notes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=gen_uuid)
book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE"), nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(20), nullable=False, default="excerpt")
excerpt: Mapped[str] = mapped_column(Text, nullable=False)
note: Mapped[str | None] = mapped_column(Text)
cfi: Mapped[str] = mapped_column(String(2000), nullable=False)
chapter_title: Mapped[str | None] = mapped_column(String(500))
color: Mapped[str] = mapped_column(String(20), nullable=False, default="yellow")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+31 -1
View File
@@ -53,4 +53,34 @@ class ParseStatus(BaseModel):
book_id: str
status: str
title: str | None
char_count: int
char_count: int
class ReadingNoteOut(BaseModel):
id: str
book_id: str
kind: str
excerpt: str
note: str | None
cfi: str
chapter_title: str | None
color: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ReadingNoteCreate(BaseModel):
kind: str = "excerpt"
excerpt: str
note: str | None = None
cfi: str
chapter_title: str | None = None
color: str = "yellow"
class ReadingNoteUpdate(BaseModel):
note: str | None = None
color: str | None = None