blocks.py 7.9 KB

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