feat: 소설 분기 시스템 및 트리 메모장 구현

🌟 주요 기능:
- 트리 구조 메모장 시스템
- 소설 분기 관리 (정사 경로 설정)
- 중앙 배치 트리 다이어그램
- 정사 경로 목차 뷰
- 인라인 편집 기능

📚 백엔드:
- MemoTree, MemoNode 모델 추가
- 정사 경로 자동 순서 관리
- 분기점에서 하나만 선택 가능한 로직
- RESTful API 엔드포인트

🎨 프론트엔드:
- memo-tree.html: 트리 다이어그램 에디터
- story-view.html: 정사 경로 목차 뷰
- SVG 연결선으로 시각적 트리 표현
- Alpine.js 기반 반응형 UI
- Monaco Editor 통합

 특별 기능:
- 정사 경로 황금색 배지 표시
- 확대/축소 및 패닝 지원
- 드래그 앤 드롭 준비
- 내보내기 및 인쇄 기능
- 인라인 편집 모달
This commit is contained in:
Hyungi Ahn
2025-08-25 10:25:10 +09:00
parent 5bfa3822ca
commit f95f67364a
16 changed files with 3992 additions and 6 deletions

View File

@@ -0,0 +1,700 @@
"""
트리 구조 메모장 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func, and_, or_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from uuid import UUID
from ...core.database import get_db
from ...models.user import User
from ...models.memo_tree import MemoTree, MemoNode, MemoNodeVersion, MemoTreeShare
from ...schemas.memo_tree import (
MemoTreeCreate, MemoTreeUpdate, MemoTreeResponse, MemoTreeWithNodes,
MemoNodeCreate, MemoNodeUpdate, MemoNodeResponse, MemoNodeMove,
MemoTreeStats, MemoSearchRequest, MemoSearchResult
)
from ..dependencies import get_current_active_user
router = APIRouter(prefix="/memo-trees", tags=["memo-trees"])
# ============================================================================
# 메모 트리 관리
# ============================================================================
@router.get("/", response_model=List[MemoTreeResponse])
async def get_user_memo_trees(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
include_archived: bool = Query(False, description="보관된 트리 포함 여부")
):
"""사용자의 메모 트리 목록 조회"""
try:
query = select(MemoTree).where(MemoTree.user_id == current_user.id)
if not include_archived:
query = query.where(MemoTree.is_archived == False)
query = query.order_by(MemoTree.updated_at.desc())
result = await db.execute(query)
trees = result.scalars().all()
# 각 트리의 노드 개수 계산
tree_responses = []
for tree in trees:
node_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
)
node_count = node_count_result.scalar() or 0
tree_dict = {
"id": str(tree.id),
"user_id": str(tree.user_id),
"title": tree.title,
"description": tree.description,
"tree_type": tree.tree_type,
"template_data": tree.template_data,
"settings": tree.settings,
"created_at": tree.created_at,
"updated_at": tree.updated_at,
"is_public": tree.is_public,
"is_archived": tree.is_archived,
"node_count": node_count
}
tree_responses.append(MemoTreeResponse(**tree_dict))
return tree_responses
except Exception as e:
print(f"ERROR in get_user_memo_trees: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get memo trees: {str(e)}"
)
@router.post("/", response_model=MemoTreeResponse)
async def create_memo_tree(
tree_data: MemoTreeCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새 메모 트리 생성"""
try:
new_tree = MemoTree(
user_id=current_user.id,
title=tree_data.title,
description=tree_data.description,
tree_type=tree_data.tree_type,
template_data=tree_data.template_data or {},
settings=tree_data.settings or {},
is_public=tree_data.is_public
)
db.add(new_tree)
await db.commit()
await db.refresh(new_tree)
tree_dict = {
"id": str(new_tree.id),
"user_id": str(new_tree.user_id),
"title": new_tree.title,
"description": new_tree.description,
"tree_type": new_tree.tree_type,
"template_data": new_tree.template_data,
"settings": new_tree.settings,
"created_at": new_tree.created_at,
"updated_at": new_tree.updated_at,
"is_public": new_tree.is_public,
"is_archived": new_tree.is_archived,
"node_count": 0
}
return MemoTreeResponse(**tree_dict)
except Exception as e:
await db.rollback()
print(f"ERROR in create_memo_tree: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create memo tree: {str(e)}"
)
@router.get("/{tree_id}", response_model=MemoTreeResponse)
async def get_memo_tree(
tree_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 트리 상세 조회"""
try:
result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
or_(
MemoTree.user_id == current_user.id,
MemoTree.is_public == True
)
)
)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 노드 개수 계산
node_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
)
node_count = node_count_result.scalar() or 0
tree_dict = {
"id": str(tree.id),
"user_id": str(tree.user_id),
"title": tree.title,
"description": tree.description,
"tree_type": tree.tree_type,
"template_data": tree.template_data,
"settings": tree.settings,
"created_at": tree.created_at,
"updated_at": tree.updated_at,
"is_public": tree.is_public,
"is_archived": tree.is_archived,
"node_count": node_count
}
return MemoTreeResponse(**tree_dict)
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_memo_tree: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get memo tree: {str(e)}"
)
@router.put("/{tree_id}", response_model=MemoTreeResponse)
async def update_memo_tree(
tree_id: UUID,
tree_data: MemoTreeUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 트리 업데이트"""
try:
result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
MemoTree.user_id == current_user.id
)
)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 업데이트할 필드들 적용
update_data = tree_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(tree, field, value)
await db.commit()
await db.refresh(tree)
# 노드 개수 계산
node_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.tree_id == tree.id)
)
node_count = node_count_result.scalar() or 0
tree_dict = {
"id": str(tree.id),
"user_id": str(tree.user_id),
"title": tree.title,
"description": tree.description,
"tree_type": tree.tree_type,
"template_data": tree.template_data,
"settings": tree.settings,
"created_at": tree.created_at,
"updated_at": tree.updated_at,
"is_public": tree.is_public,
"is_archived": tree.is_archived,
"node_count": node_count
}
return MemoTreeResponse(**tree_dict)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in update_memo_tree: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update memo tree: {str(e)}"
)
@router.delete("/{tree_id}")
async def delete_memo_tree(
tree_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 트리 삭제"""
try:
result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
MemoTree.user_id == current_user.id
)
)
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 트리 삭제 (CASCADE로 관련 노드들도 자동 삭제됨)
await db.delete(tree)
await db.commit()
return {"message": "Memo tree deleted successfully"}
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in delete_memo_tree: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete memo tree: {str(e)}"
)
# ============================================================================
# 메모 노드 관리
# ============================================================================
@router.get("/{tree_id}/nodes", response_model=List[MemoNodeResponse])
async def get_memo_tree_nodes(
tree_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 트리의 모든 노드 조회"""
try:
# 트리 접근 권한 확인
tree_result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
or_(
MemoTree.user_id == current_user.id,
MemoTree.is_public == True
)
)
)
)
tree = tree_result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 노드들 조회
result = await db.execute(
select(MemoNode)
.where(MemoNode.tree_id == tree_id)
.order_by(MemoNode.path, MemoNode.sort_order)
)
nodes = result.scalars().all()
# 각 노드의 자식 개수 계산
node_responses = []
for node in nodes:
children_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
)
children_count = children_count_result.scalar() or 0
node_dict = {
"id": str(node.id),
"tree_id": str(node.tree_id),
"parent_id": str(node.parent_id) if node.parent_id else None,
"user_id": str(node.user_id),
"title": node.title,
"content": node.content,
"node_type": node.node_type,
"sort_order": node.sort_order,
"depth_level": node.depth_level,
"path": node.path,
"tags": node.tags or [],
"node_metadata": node.node_metadata or {},
"status": node.status,
"word_count": node.word_count,
"is_canonical": node.is_canonical,
"canonical_order": node.canonical_order,
"story_path": node.story_path,
"created_at": node.created_at,
"updated_at": node.updated_at,
"children_count": children_count
}
node_responses.append(MemoNodeResponse(**node_dict))
return node_responses
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_memo_tree_nodes: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get memo tree nodes: {str(e)}"
)
@router.post("/{tree_id}/nodes", response_model=MemoNodeResponse)
async def create_memo_node(
tree_id: UUID,
node_data: MemoNodeCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새 메모 노드 생성"""
try:
# 트리 접근 권한 확인
tree_result = await db.execute(
select(MemoTree).where(
and_(
MemoTree.id == tree_id,
MemoTree.user_id == current_user.id
)
)
)
tree = tree_result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo tree not found"
)
# 부모 노드 확인 (있다면)
if node_data.parent_id:
parent_result = await db.execute(
select(MemoNode).where(
and_(
MemoNode.id == UUID(node_data.parent_id),
MemoNode.tree_id == tree_id
)
)
)
parent_node = parent_result.scalar_one_or_none()
if not parent_node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent node not found"
)
# 단어 수 계산
word_count = 0
if node_data.content:
word_count = len(node_data.content.replace('\n', ' ').split())
new_node = MemoNode(
tree_id=tree_id,
parent_id=UUID(node_data.parent_id) if node_data.parent_id else None,
user_id=current_user.id,
title=node_data.title,
content=node_data.content,
node_type=node_data.node_type,
sort_order=node_data.sort_order,
tags=node_data.tags or [],
node_metadata=node_data.node_metadata or {},
status=node_data.status,
word_count=word_count,
is_canonical=node_data.is_canonical or False
)
db.add(new_node)
await db.commit()
await db.refresh(new_node)
node_dict = {
"id": str(new_node.id),
"tree_id": str(new_node.tree_id),
"parent_id": str(new_node.parent_id) if new_node.parent_id else None,
"user_id": str(new_node.user_id),
"title": new_node.title,
"content": new_node.content,
"node_type": new_node.node_type,
"sort_order": new_node.sort_order,
"depth_level": new_node.depth_level,
"path": new_node.path,
"tags": new_node.tags or [],
"node_metadata": new_node.node_metadata or {},
"status": new_node.status,
"word_count": new_node.word_count,
"is_canonical": new_node.is_canonical,
"canonical_order": new_node.canonical_order,
"story_path": new_node.story_path,
"created_at": new_node.created_at,
"updated_at": new_node.updated_at,
"children_count": 0
}
return MemoNodeResponse(**node_dict)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in create_memo_node: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create memo node: {str(e)}"
)
@router.get("/nodes/{node_id}", response_model=MemoNodeResponse)
async def get_memo_node(
node_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 노드 상세 조회"""
try:
result = await db.execute(
select(MemoNode)
.options(selectinload(MemoNode.tree))
.where(MemoNode.id == node_id)
)
node = result.scalar_one_or_none()
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo node not found"
)
# 접근 권한 확인
if node.tree.user_id != current_user.id and not node.tree.is_public:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this node"
)
# 자식 개수 계산
children_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
)
children_count = children_count_result.scalar() or 0
node_dict = {
"id": str(node.id),
"tree_id": str(node.tree_id),
"parent_id": str(node.parent_id) if node.parent_id else None,
"user_id": str(node.user_id),
"title": node.title,
"content": node.content,
"node_type": node.node_type,
"sort_order": node.sort_order,
"depth_level": node.depth_level,
"path": node.path,
"tags": node.tags or [],
"node_metadata": node.node_metadata or {},
"status": node.status,
"word_count": node.word_count,
"is_canonical": node.is_canonical,
"canonical_order": node.canonical_order,
"story_path": node.story_path,
"created_at": node.created_at,
"updated_at": node.updated_at,
"children_count": children_count
}
return MemoNodeResponse(**node_dict)
except HTTPException:
raise
except Exception as e:
print(f"ERROR in get_memo_node: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get memo node: {str(e)}"
)
@router.put("/nodes/{node_id}", response_model=MemoNodeResponse)
async def update_memo_node(
node_id: UUID,
node_data: MemoNodeUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 노드 업데이트"""
try:
result = await db.execute(
select(MemoNode)
.options(selectinload(MemoNode.tree))
.where(MemoNode.id == node_id)
)
node = result.scalar_one_or_none()
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo node not found"
)
# 접근 권한 확인 (소유자만 수정 가능)
if node.tree.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this node"
)
# 업데이트할 필드들 적용
update_data = node_data.dict(exclude_unset=True)
for field, value in update_data.items():
if field == "parent_id" and value:
# 부모 노드 유효성 검사
parent_result = await db.execute(
select(MemoNode).where(
and_(
MemoNode.id == UUID(value),
MemoNode.tree_id == node.tree_id
)
)
)
parent_node = parent_result.scalar_one_or_none()
if not parent_node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent node not found"
)
setattr(node, field, UUID(value))
elif field == "parent_id" and value is None:
setattr(node, field, None)
else:
setattr(node, field, value)
# 내용이 업데이트되면 단어 수 재계산
if "content" in update_data:
word_count = 0
if node.content:
word_count = len(node.content.replace('\n', ' ').split())
node.word_count = word_count
await db.commit()
await db.refresh(node)
# 자식 개수 계산
children_count_result = await db.execute(
select(func.count(MemoNode.id)).where(MemoNode.parent_id == node.id)
)
children_count = children_count_result.scalar() or 0
node_dict = {
"id": str(node.id),
"tree_id": str(node.tree_id),
"parent_id": str(node.parent_id) if node.parent_id else None,
"user_id": str(node.user_id),
"title": node.title,
"content": node.content,
"node_type": node.node_type,
"sort_order": node.sort_order,
"depth_level": node.depth_level,
"path": node.path,
"tags": node.tags or [],
"node_metadata": node.node_metadata or {},
"status": node.status,
"word_count": node.word_count,
"is_canonical": node.is_canonical,
"canonical_order": node.canonical_order,
"story_path": node.story_path,
"created_at": node.created_at,
"updated_at": node.updated_at,
"children_count": children_count
}
return MemoNodeResponse(**node_dict)
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in update_memo_node: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update memo node: {str(e)}"
)
@router.delete("/nodes/{node_id}")
async def delete_memo_node(
node_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""메모 노드 삭제"""
try:
result = await db.execute(
select(MemoNode)
.options(selectinload(MemoNode.tree))
.where(MemoNode.id == node_id)
)
node = result.scalar_one_or_none()
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Memo node not found"
)
# 접근 권한 확인 (소유자만 삭제 가능)
if node.tree.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to delete this node"
)
# 노드 삭제 (CASCADE로 자식 노드들도 자동 삭제됨)
await db.delete(node)
await db.commit()
return {"message": "Memo node deleted successfully"}
except HTTPException:
raise
except Exception as e:
await db.rollback()
print(f"ERROR in delete_memo_node: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete memo node: {str(e)}"
)

View File

@@ -9,7 +9,7 @@ import uvicorn
from .core.config import settings
from .core.database import init_db
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees
@asynccontextmanager
@@ -51,6 +51,7 @@ app.include_router(books.router, prefix="/api/books", tags=["서적"])
app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"])
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
app.include_router(search.router, prefix="/api/search", tags=["검색"])
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
@app.get("/")

View File

@@ -0,0 +1,111 @@
"""
트리 구조 메모장 모델
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey, ARRAY, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from ..core.database import Base
class MemoTree(Base):
"""메모 트리 (프로젝트/워크스페이스)"""
__tablename__ = "memo_trees"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
title = Column(String(255), nullable=False)
description = Column(Text)
tree_type = Column(String(50), default="general") # 'novel', 'research', 'project', 'general'
template_data = Column(JSON) # 템플릿별 메타데이터
settings = Column(JSON, default={}) # 트리별 설정
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
is_public = Column(Boolean, default=False)
is_archived = Column(Boolean, default=False)
# 관계
user = relationship("User", back_populates="memo_trees")
nodes = relationship("MemoNode", back_populates="tree", cascade="all, delete-orphan")
shares = relationship("MemoTreeShare", back_populates="tree", cascade="all, delete-orphan")
class MemoNode(Base):
"""메모 노드 (트리의 각 노드)"""
__tablename__ = "memo_nodes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
parent_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"))
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# 기본 정보
title = Column(String(500), nullable=False)
content = Column(Text) # Markdown 형식
node_type = Column(String(50), default="memo") # 'folder', 'memo', 'chapter', 'character', 'plot'
# 트리 구조 관리
sort_order = Column(Integer, default=0)
depth_level = Column(Integer, default=0)
path = Column(Text) # 경로 저장 (예: /1/3/7)
# 메타데이터
tags = Column(ARRAY(String)) # 태그 배열
node_metadata = Column(JSON, default={}) # 노드별 메타데이터
# 상태 관리
status = Column(String(50), default="draft") # 'draft', 'writing', 'review', 'complete'
word_count = Column(Integer, default=0)
# 정사 경로 관련 필드
is_canonical = Column(Boolean, default=False) # 정사 경로 여부
canonical_order = Column(Integer, nullable=True) # 정사 경로 순서
story_path = Column(Text, nullable=True) # 정사 경로 문자열
# 시간 정보
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# 관계
tree = relationship("MemoTree", back_populates="nodes")
user = relationship("User", back_populates="memo_nodes")
parent = relationship("MemoNode", remote_side=[id], back_populates="children")
children = relationship("MemoNode", back_populates="parent", cascade="all, delete-orphan")
versions = relationship("MemoNodeVersion", back_populates="node", cascade="all, delete-orphan")
class MemoNodeVersion(Base):
"""메모 노드 버전 관리"""
__tablename__ = "memo_node_versions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
node_id = Column(UUID(as_uuid=True), ForeignKey("memo_nodes.id", ondelete="CASCADE"), nullable=False)
version_number = Column(Integer, nullable=False)
title = Column(String(500), nullable=False)
content = Column(Text)
node_metadata = Column(JSON, default={})
created_at = Column(DateTime(timezone=True), server_default=func.now())
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# 관계
node = relationship("MemoNode", back_populates="versions")
creator = relationship("User")
class MemoTreeShare(Base):
"""메모 트리 공유 (협업 기능)"""
__tablename__ = "memo_tree_shares"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tree_id = Column(UUID(as_uuid=True), ForeignKey("memo_trees.id", ondelete="CASCADE"), nullable=False)
shared_with_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
permission_level = Column(String(20), default="read") # 'read', 'write', 'admin'
created_at = Column(DateTime(timezone=True), server_default=func.now())
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# 관계
tree = relationship("MemoTree", back_populates="shares")
shared_with_user = relationship("User", foreign_keys=[shared_with_user_id])
creator = relationship("User", foreign_keys=[created_by])

View File

@@ -3,6 +3,7 @@
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
@@ -30,5 +31,9 @@ class User(Base):
language = Column(String(10), default="ko") # ko, en
timezone = Column(String(50), default="Asia/Seoul")
# 관계 (lazy loading을 위해 문자열로 참조)
memo_trees = relationship("MemoTree", back_populates="user", lazy="dynamic")
memo_nodes = relationship("MemoNode", back_populates="user", lazy="dynamic")
def __repr__(self):
return f"<User(email='{self.email}', full_name='{self.full_name}')>"

View File

@@ -0,0 +1,205 @@
"""
트리 구조 메모장 Pydantic 스키마
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
from uuid import UUID
# 기본 스키마들
class MemoTreeBase(BaseModel):
"""메모 트리 기본 스키마"""
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
tree_type: str = Field(default="general", pattern="^(general|novel|research|project)$")
template_data: Optional[Dict[str, Any]] = None
settings: Optional[Dict[str, Any]] = None
is_public: bool = False
class MemoTreeCreate(MemoTreeBase):
"""메모 트리 생성 요청"""
pass
class MemoTreeUpdate(BaseModel):
"""메모 트리 업데이트 요청"""
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
tree_type: Optional[str] = Field(None, pattern="^(general|novel|research|project)$")
template_data: Optional[Dict[str, Any]] = None
settings: Optional[Dict[str, Any]] = None
is_public: Optional[bool] = None
is_archived: Optional[bool] = None
class MemoTreeResponse(MemoTreeBase):
"""메모 트리 응답"""
id: str
user_id: str
created_at: datetime
updated_at: Optional[datetime]
is_archived: bool
node_count: Optional[int] = 0 # 노드 개수
class Config:
from_attributes = True
# 메모 노드 스키마들
class MemoNodeBase(BaseModel):
"""메모 노드 기본 스키마"""
title: str = Field(..., min_length=1, max_length=500)
content: Optional[str] = None
node_type: str = Field(default="memo", pattern="^(folder|memo|chapter|character|plot)$")
tags: Optional[List[str]] = None
node_metadata: Optional[Dict[str, Any]] = None
status: str = Field(default="draft", pattern="^(draft|writing|review|complete)$")
# 정사 경로 관련 필드
is_canonical: Optional[bool] = False
canonical_order: Optional[int] = None
class MemoNodeCreate(MemoNodeBase):
"""메모 노드 생성 요청"""
tree_id: str
parent_id: Optional[str] = None
sort_order: Optional[int] = 0
class MemoNodeUpdate(BaseModel):
"""메모 노드 업데이트 요청"""
title: Optional[str] = Field(None, min_length=1, max_length=500)
content: Optional[str] = None
node_type: Optional[str] = Field(None, pattern="^(folder|memo|chapter|character|plot)$")
parent_id: Optional[str] = None
sort_order: Optional[int] = None
tags: Optional[List[str]] = None
node_metadata: Optional[Dict[str, Any]] = None
status: Optional[str] = Field(None, pattern="^(draft|writing|review|complete)$")
# 정사 경로 관련 필드
is_canonical: Optional[bool] = None
canonical_order: Optional[int] = None
class MemoNodeMove(BaseModel):
"""메모 노드 이동 요청"""
parent_id: Optional[str] = None
sort_order: int = 0
class MemoNodeResponse(MemoNodeBase):
"""메모 노드 응답"""
id: str
tree_id: str
parent_id: Optional[str]
user_id: str
sort_order: int
depth_level: int
path: Optional[str]
word_count: int
created_at: datetime
updated_at: Optional[datetime]
# 정사 경로 관련 필드
is_canonical: bool
canonical_order: Optional[int]
story_path: Optional[str]
# 관계 데이터
children_count: Optional[int] = 0
class Config:
from_attributes = True
# 트리 구조 응답
class MemoTreeWithNodes(MemoTreeResponse):
"""노드가 포함된 메모 트리 응답"""
nodes: List[MemoNodeResponse] = []
# 노드 버전 스키마들
class MemoNodeVersionResponse(BaseModel):
"""메모 노드 버전 응답"""
id: str
node_id: str
version_number: int
title: str
content: Optional[str]
node_metadata: Optional[Dict[str, Any]]
created_at: datetime
created_by: str
class Config:
from_attributes = True
# 공유 스키마들
class MemoTreeShareCreate(BaseModel):
"""메모 트리 공유 생성 요청"""
shared_with_user_email: str
permission_level: str = Field(default="read", pattern="^(read|write|admin)$")
class MemoTreeShareResponse(BaseModel):
"""메모 트리 공유 응답"""
id: str
tree_id: str
shared_with_user_id: str
shared_with_user_email: str
shared_with_user_name: str
permission_level: str
created_at: datetime
created_by: str
class Config:
from_attributes = True
# 검색 및 필터링
class MemoSearchRequest(BaseModel):
"""메모 검색 요청"""
query: str = Field(..., min_length=1)
tree_id: Optional[str] = None
node_types: Optional[List[str]] = None
tags: Optional[List[str]] = None
status: Optional[List[str]] = None
class MemoSearchResult(BaseModel):
"""메모 검색 결과"""
node: MemoNodeResponse
tree: MemoTreeResponse
matches: List[Dict[str, Any]] # 매치된 부분들
relevance_score: float
# 통계 스키마
class MemoTreeStats(BaseModel):
"""메모 트리 통계"""
total_nodes: int
nodes_by_type: Dict[str, int]
nodes_by_status: Dict[str, int]
total_words: int
last_updated: Optional[datetime]
# 내보내기 스키마
class ExportRequest(BaseModel):
"""내보내기 요청"""
tree_id: str
format: str = Field(..., pattern="^(markdown|html|pdf|docx)$")
include_metadata: bool = True
node_types: Optional[List[str]] = None
class ExportResponse(BaseModel):
"""내보내기 응답"""
file_url: str
file_name: str
file_size: int
created_at: datetime