feat: add EPUB reading notes and highlights
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user