blocks.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. """blocks.py — Blocks 操作 API"""
  2. from fastapi import APIRouter, Depends, Query
  3. from sqlalchemy.ext.asyncio import AsyncSession
  4. from app.api.v1 import ok
  5. from app.core.dependencies import get_db
  6. from app.schemas.block import (
  7. BlockBase,
  8. BlockCreate,
  9. BlockSearchResult,
  10. BlockStats,
  11. BlockUpdate,
  12. TOCItem,
  13. )
  14. from app.services.content_db import ContentDB
  15. from app.services.document_service import DocumentService
  16. router = APIRouter(prefix="/documents/{documentId}/blocks", tags=["Blocks"])
  17. @router.post("", summary="插入新 block")
  18. async def create_block(
  19. documentId: str,
  20. body: BlockCreate,
  21. db: AsyncSession = Depends(get_db),
  22. ) -> dict:
  23. """在指定位置插入新 block
  24. Args:
  25. documentId: 文档 ID
  26. body: Block 创建请求
  27. - type: Block 类型(heading/paragraph/table/image/toc)
  28. - content: Block 内容
  29. - level: 标题级别(heading 必填:1-6,其他类型固定为 0)
  30. - word_style: Word 样式名(可选)
  31. - style: 自定义样式(可选)
  32. - metadata: 元数据(可选)
  33. - after_block_id: 插入位置(null = 末尾)
  34. Returns:
  35. 包含新 block ID 的响应
  36. """
  37. # 1. 参数校验
  38. if body.type == 'heading':
  39. if body.level not in range(1, 7):
  40. return {"code": 400, "message": "heading 的 level 必须是 1-6"}
  41. else:
  42. # 其他类型强制设置为 0
  43. body.level = 0
  44. # 2. 获取文档
  45. svc = DocumentService(db)
  46. doc = await svc.get_document(documentId)
  47. try:
  48. with ContentDB(doc.content_db_path) as content_db:
  49. # 3. 计算 index(同类型同级别的稀疏序号)
  50. new_index = content_db.calculate_index_for_insert(
  51. body.after_block_id,
  52. body.type,
  53. body.level
  54. )
  55. # 4. 计算 block_order(文档全局位置)
  56. new_block_order = content_db.calculate_block_order_for_insert(
  57. body.after_block_id
  58. )
  59. # 5. 生成 Block ID
  60. new_id = ContentDB.generate_block_id(body.type, body.level, new_index)
  61. # 6. 构建新 block
  62. new_block = {
  63. 'id': new_id,
  64. 'block_order': new_block_order,
  65. 'type': body.type,
  66. 'level': body.level,
  67. 'index': new_index,
  68. 'content': body.content,
  69. 'word_style': body.word_style,
  70. 'style': body.style,
  71. 'metadata': body.metadata
  72. }
  73. # 7. 插入数据库
  74. content_db.insert_blocks([new_block])
  75. except ValueError as e:
  76. return {"code": 404, "message": str(e)}
  77. except Exception as e:
  78. return {"code": 500, "message": f"插入失败: {str(e)}"}
  79. # 8. 更新文档时间戳
  80. await svc.update_document_timestamp(documentId)
  81. return ok({"blockId": new_id, "message": "Block created successfully"})
  82. @router.get("", summary="获取文档的所有 blocks")
  83. async def get_blocks(
  84. documentId: str,
  85. db: AsyncSession = Depends(get_db),
  86. ) -> dict:
  87. """获取文档的所有 blocks,按 block_order 排序"""
  88. svc = DocumentService(db)
  89. doc = await svc.get_document(documentId)
  90. with ContentDB(doc.content_db_path) as content_db:
  91. blocks = content_db.get_blocks()
  92. return ok({"blocks": blocks})
  93. @router.get("/{blockId}", summary="获取单个 block")
  94. async def get_block(
  95. documentId: str,
  96. blockId: str,
  97. db: AsyncSession = Depends(get_db),
  98. ) -> dict:
  99. """获取指定 block 的详细信息"""
  100. svc = DocumentService(db)
  101. doc = await svc.get_document(documentId)
  102. with ContentDB(doc.content_db_path) as content_db:
  103. block = content_db.get_block_by_id(blockId)
  104. if not block:
  105. return {"code": 404, "message": f"Block not found: {blockId}"}
  106. return ok({"block": block})
  107. @router.put("/{blockId}", summary="更新 block")
  108. async def update_block(
  109. documentId: str,
  110. blockId: str,
  111. body: BlockUpdate,
  112. db: AsyncSession = Depends(get_db),
  113. ) -> dict:
  114. """更新指定 block 的内容或样式"""
  115. svc = DocumentService(db)
  116. doc = await svc.get_document(documentId)
  117. # ★ 获取原始 block
  118. with ContentDB(doc.content_db_path) as content_db:
  119. original_block = content_db.get_block_by_id(blockId)
  120. if not original_block:
  121. return {"code": 404, "message": f"Block not found: {blockId}"}
  122. # ★ TOC block 验证
  123. if original_block.get("type") == "toc":
  124. # TOC block 只允许更新 metadata(如删除标记)
  125. if body.content is not None:
  126. return {
  127. "code": 403,
  128. "message": "TOC block content is readonly and auto-generated"
  129. }
  130. # 只允许更新特定 metadata 字段
  131. if body.metadata:
  132. allowed_keys = {"deletable"}
  133. if not set(body.metadata.keys()).issubset(allowed_keys):
  134. return {
  135. "code": 403,
  136. "message": f"TOC block only allows updating: {allowed_keys}"
  137. }
  138. # 更新 block
  139. updates = body.model_dump(exclude_none=True)
  140. with ContentDB(doc.content_db_path) as content_db:
  141. content_db.update_block(blockId, updates)
  142. # 更新文档时间戳
  143. await svc.update_document_timestamp(documentId)
  144. return ok({"blockId": blockId, "message": "Block updated successfully"})
  145. @router.delete("/{blockId}", summary="删除 block")
  146. async def delete_block(
  147. documentId: str,
  148. blockId: str,
  149. db: AsyncSession = Depends(get_db),
  150. ) -> dict:
  151. """删除指定 block"""
  152. svc = DocumentService(db)
  153. doc = await svc.get_document(documentId)
  154. with ContentDB(doc.content_db_path) as content_db:
  155. content_db.delete_block(blockId)
  156. # 更新文档时间戳
  157. await svc.update_document_timestamp(documentId)
  158. return ok({"message": "Block deleted successfully"})
  159. @router.get("/search", summary="搜索 blocks")
  160. async def search_blocks(
  161. documentId: str,
  162. q: str = Query(..., description="搜索关键词"),
  163. type: str = Query(None, description="Block 类型筛选"),
  164. db: AsyncSession = Depends(get_db),
  165. ) -> dict:
  166. """在文档中搜索包含关键词的 blocks"""
  167. svc = DocumentService(db)
  168. doc = await svc.get_document(documentId)
  169. with ContentDB(doc.content_db_path) as content_db:
  170. blocks = content_db.search_blocks(q, type)
  171. return ok({
  172. "blocks": blocks,
  173. "total": len(blocks),
  174. "query": q
  175. })
  176. @router.get("/toc", summary="获取目录树")
  177. async def get_toc(
  178. documentId: str,
  179. db: AsyncSession = Depends(get_db),
  180. ) -> dict:
  181. """获取文档的标题目录树"""
  182. svc = DocumentService(db)
  183. doc = await svc.get_document(documentId)
  184. with ContentDB(doc.content_db_path) as content_db:
  185. headings = content_db.get_headings()
  186. # 构建树形结构
  187. toc = _build_toc_tree(headings)
  188. return ok({"toc": toc})
  189. @router.get("/stats", summary="获取统计信息")
  190. async def get_stats(
  191. documentId: str,
  192. db: AsyncSession = Depends(get_db),
  193. ) -> dict:
  194. """获取文档的 block 统计信息"""
  195. svc = DocumentService(db)
  196. doc = await svc.get_document(documentId)
  197. with ContentDB(doc.content_db_path) as content_db:
  198. stats = content_db.get_stats()
  199. return ok(stats)
  200. def _build_toc_tree(headings: list[dict]) -> list[dict]:
  201. """构建目录树
  202. Args:
  203. headings: 标题 block 列表
  204. Returns:
  205. 树形结构的目录
  206. """
  207. root = []
  208. stack = []
  209. for h in headings:
  210. # 提取内容(可能是字符串或富文本数组)
  211. content = h["content"]
  212. if isinstance(content, list):
  213. # 富文本:拼接所有片段
  214. content = "".join(seg.get("text", "") for seg in content)
  215. node = {
  216. "id": h["id"],
  217. "level": h["level"],
  218. "content": content,
  219. "children": []
  220. }
  221. # 找到父节点
  222. while stack and stack[-1]["level"] >= h["level"]:
  223. stack.pop()
  224. if stack:
  225. stack[-1]["children"].append(node)
  226. else:
  227. root.append(node)
  228. stack.append(node)
  229. return root