diff --git a/backend/database/migrations/005_create_memo_tree_tables.sql b/backend/database/migrations/005_create_memo_tree_tables.sql new file mode 100644 index 0000000..d7a011f --- /dev/null +++ b/backend/database/migrations/005_create_memo_tree_tables.sql @@ -0,0 +1,153 @@ +-- 트리 구조 메모장 테이블 생성 +-- 005_create_memo_tree_tables.sql + +-- 메모 트리 (프로젝트/워크스페이스) +CREATE TABLE memo_trees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general' + template_data JSONB, -- 템플릿별 메타데이터 + settings JSONB DEFAULT '{}', -- 트리별 설정 + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_public BOOLEAN DEFAULT FALSE, + is_archived BOOLEAN DEFAULT FALSE +); + +-- 메모 노드 (트리의 각 노드) +CREATE TABLE memo_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- 기본 정보 + title VARCHAR(500) NOT NULL, + content TEXT, -- 실제 메모 내용 (Markdown) + node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot' + + -- 트리 구조 관리 + sort_order INTEGER DEFAULT 0, + depth_level INTEGER DEFAULT 0, + path TEXT, -- 경로 저장 (예: /1/3/7) + + -- 메타데이터 + tags TEXT[], -- 태그 배열 + node_metadata JSONB DEFAULT '{}', -- 노드별 메타데이터 (캐릭터 정보, 플롯 정보 등) + + -- 상태 관리 + status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete' + word_count INTEGER DEFAULT 0, + + -- 시간 정보 + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- 제약 조건 + CONSTRAINT no_self_reference CHECK (id != parent_id) +); + +-- 메모 노드 버전 관리 (선택적) +CREATE TABLE memo_node_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + title VARCHAR(500) NOT NULL, + content TEXT, + node_metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(node_id, version_number) +); + +-- 메모 트리 공유 (협업 기능) +CREATE TABLE memo_tree_shares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin' + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(tree_id, shared_with_user_id) +); + +-- 인덱스 생성 +CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id); +CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type); +CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id); +CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id); +CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id); +CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/')); +CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags); +CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type); +CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id); +CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id); + +-- 트리거 함수: updated_at 자동 업데이트 +CREATE OR REPLACE FUNCTION update_memo_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 트리거 생성 +CREATE TRIGGER memo_trees_updated_at + BEFORE UPDATE ON memo_trees + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +CREATE TRIGGER memo_nodes_updated_at + BEFORE UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +-- 트리거 함수: 경로 자동 업데이트 +CREATE OR REPLACE FUNCTION update_memo_node_path() +RETURNS TRIGGER AS $$ +BEGIN + -- 루트 노드인 경우 + IF NEW.parent_id IS NULL THEN + NEW.path = '/' || NEW.id::text; + NEW.depth_level = 0; + ELSE + -- 부모 노드의 경로를 가져와서 확장 + SELECT path || '/' || NEW.id::text, depth_level + 1 + INTO NEW.path, NEW.depth_level + FROM memo_nodes + WHERE id = NEW.parent_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 경로 업데이트 트리거 +CREATE TRIGGER memo_nodes_path_update + BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_node_path(); + +-- 샘플 데이터 (개발용) +-- 소설 템플릿 예시 +INSERT INTO memo_trees (user_id, title, description, tree_type, template_data) +SELECT + u.id, + '내 첫 번째 소설', + '판타지 소설 프로젝트', + 'novel', + '{ + "genre": "fantasy", + "target_length": 100000, + "chapters_planned": 20, + "main_characters": [], + "world_building": {} + }'::jsonb +FROM users u +WHERE u.email = 'admin@test.com' +LIMIT 1; diff --git a/backend/database/migrations/006_add_canonical_path.sql b/backend/database/migrations/006_add_canonical_path.sql new file mode 100644 index 0000000..1474cda --- /dev/null +++ b/backend/database/migrations/006_add_canonical_path.sql @@ -0,0 +1,98 @@ +-- 006_add_canonical_path.sql +-- 정사 경로 표시를 위한 필드 추가 + +-- memo_nodes 테이블에 정사 경로 관련 필드 추가 +ALTER TABLE memo_nodes +ADD COLUMN is_canonical BOOLEAN DEFAULT FALSE, +ADD COLUMN canonical_order INTEGER DEFAULT NULL, +ADD COLUMN story_path TEXT DEFAULT NULL; -- 정사 경로 저장 (예: /1/3/7) + +-- 정사 경로 순서를 위한 인덱스 추가 +CREATE INDEX idx_memo_nodes_canonical_order ON memo_nodes(tree_id, canonical_order) WHERE is_canonical = TRUE; + +-- 트리별 정사 경로 통계를 위한 뷰 생성 +CREATE OR REPLACE VIEW memo_tree_canonical_stats AS +SELECT + t.id as tree_id, + t.title as tree_title, + COUNT(n.id) as total_nodes, + COUNT(CASE WHEN n.is_canonical = TRUE THEN 1 END) as canonical_nodes, + MAX(n.canonical_order) as max_canonical_order, + STRING_AGG( + CASE WHEN n.is_canonical = TRUE THEN n.title END, + ' → ' + ORDER BY n.canonical_order + ) as canonical_story_path +FROM memo_trees t +LEFT JOIN memo_nodes n ON t.id = n.tree_id +GROUP BY t.id, t.title; + +-- 정사 경로 순서 자동 업데이트 함수 (분기점에서 하나만 선택 가능) +CREATE OR REPLACE FUNCTION update_canonical_order() +RETURNS TRIGGER AS $$ +BEGIN + -- 정사로 설정될 때 + IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN + -- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택) + IF NEW.parent_id IS NOT NULL THEN + UPDATE memo_nodes + SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL + WHERE tree_id = NEW.tree_id + AND parent_id = NEW.parent_id + AND id != NEW.id + AND is_canonical = TRUE; + END IF; + + -- 부모 노드의 순서를 기준으로 순서 계산 + IF NEW.parent_id IS NULL THEN + -- 루트 노드는 항상 1 + NEW.canonical_order = 1; + ELSE + -- 부모 노드의 순서 + 1 + SELECT COALESCE(parent.canonical_order, 0) + 1 + INTO NEW.canonical_order + FROM memo_nodes parent + WHERE parent.id = NEW.parent_id AND parent.is_canonical = TRUE; + + -- 부모가 정사가 아니면 순서 할당 안함 + IF NEW.canonical_order IS NULL THEN + NEW.canonical_order = NULL; + END IF; + END IF; + + -- 정사 경로 업데이트 + NEW.story_path = COALESCE(NEW.path, ''); + END IF; + + -- 정사에서 제외될 때 순서 제거 + IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN + NEW.canonical_order = NULL; + NEW.story_path = NULL; + + -- 뒤의 순서들을 앞으로 당기기 + UPDATE memo_nodes + SET canonical_order = canonical_order - 1 + WHERE tree_id = NEW.tree_id + AND is_canonical = TRUE + AND canonical_order > OLD.canonical_order; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 트리거 생성 +DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes; +CREATE TRIGGER trigger_update_canonical_order + BEFORE UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_canonical_order(); + +-- 기존 루트 노드들을 정사로 설정 (기본값) +UPDATE memo_nodes +SET is_canonical = TRUE, canonical_order = 1 +WHERE parent_id IS NULL AND is_canonical = FALSE; + +COMMENT ON COLUMN memo_nodes.is_canonical IS '정사 경로 여부 (소설의 메인 스토리라인)'; +COMMENT ON COLUMN memo_nodes.canonical_order IS '정사 경로에서의 순서 (1부터 시작)'; +COMMENT ON COLUMN memo_nodes.story_path IS '정사 경로 문자열 표현'; diff --git a/backend/database/migrations/007_fix_canonical_order.sql b/backend/database/migrations/007_fix_canonical_order.sql new file mode 100644 index 0000000..5be147d --- /dev/null +++ b/backend/database/migrations/007_fix_canonical_order.sql @@ -0,0 +1,97 @@ +-- 007_fix_canonical_order.sql +-- 정사 경로 순서 계산 로직 수정 + +-- 기존 트리거 삭제 +DROP TRIGGER IF EXISTS trigger_update_canonical_order ON memo_nodes; +DROP FUNCTION IF EXISTS update_canonical_order(); + +-- 정사 경로 순서를 올바르게 계산하는 함수 +CREATE OR REPLACE FUNCTION update_canonical_order() +RETURNS TRIGGER AS $$ +BEGIN + -- 정사로 설정될 때 + IF NEW.is_canonical = TRUE AND (OLD.is_canonical IS NULL OR OLD.is_canonical = FALSE) THEN + -- 같은 부모를 가진 다른 형제 노드들의 정사 상태 해제 (분기점에서 하나만 선택) + IF NEW.parent_id IS NOT NULL THEN + UPDATE memo_nodes + SET is_canonical = FALSE, canonical_order = NULL, story_path = NULL + WHERE tree_id = NEW.tree_id + AND parent_id = NEW.parent_id + AND id != NEW.id + AND is_canonical = TRUE; + END IF; + + -- 정사 경로 업데이트 + NEW.story_path = COALESCE(NEW.path, ''); + + -- 순서는 별도 함수에서 일괄 계산 + PERFORM recalculate_canonical_orders(NEW.tree_id); + END IF; + + -- 정사에서 제외될 때 + IF NEW.is_canonical = FALSE AND OLD.is_canonical = TRUE THEN + NEW.canonical_order = NULL; + NEW.story_path = NULL; + + -- 순서 재계산 + PERFORM recalculate_canonical_orders(NEW.tree_id); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 트리별 정사 경로 순서를 DFS로 재계산하는 함수 +CREATE OR REPLACE FUNCTION recalculate_canonical_orders(tree_uuid UUID) +RETURNS VOID AS $$ +DECLARE + current_order INTEGER := 1; +BEGIN + -- 모든 정사 노드의 순서를 NULL로 초기화 + UPDATE memo_nodes + SET canonical_order = NULL + WHERE tree_id = tree_uuid AND is_canonical = TRUE; + + -- DFS로 순서 할당 (재귀 CTE 사용) + WITH RECURSIVE canonical_path AS ( + -- 루트 노드들 (정사인 것만) + SELECT id, parent_id, title, 1 as order_num, ARRAY[id] as path + FROM memo_nodes + WHERE tree_id = tree_uuid + AND parent_id IS NULL + AND is_canonical = TRUE + + UNION ALL + + -- 자식 노드들 (정사인 것만) + SELECT n.id, n.parent_id, n.title, + cp.order_num + 1 as order_num, + cp.path || n.id + FROM memo_nodes n + INNER JOIN canonical_path cp ON n.parent_id = cp.id + WHERE n.tree_id = tree_uuid + AND n.is_canonical = TRUE + ) + UPDATE memo_nodes + SET canonical_order = cp.order_num + FROM canonical_path cp + WHERE memo_nodes.id = cp.id; +END; +$$ LANGUAGE plpgsql; + +-- 트리거 다시 생성 +CREATE TRIGGER trigger_update_canonical_order + AFTER UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_canonical_order(); + +-- 기존 데이터의 순서 재계산 +DO $$ +DECLARE + tree_rec RECORD; +BEGIN + FOR tree_rec IN SELECT DISTINCT tree_id FROM memo_nodes WHERE is_canonical = TRUE + LOOP + PERFORM recalculate_canonical_orders(tree_rec.tree_id); + END LOOP; +END $$; diff --git a/backend/src/api/routes/memo_trees.py b/backend/src/api/routes/memo_trees.py new file mode 100644 index 0000000..7716bc9 --- /dev/null +++ b/backend/src/api/routes/memo_trees.py @@ -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)}" + ) diff --git a/backend/src/main.py b/backend/src/main.py index 15fed77..102571b 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -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("/") diff --git a/backend/src/models/memo_tree.py b/backend/src/models/memo_tree.py new file mode 100644 index 0000000..04c323a --- /dev/null +++ b/backend/src/models/memo_tree.py @@ -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]) diff --git a/backend/src/models/user.py b/backend/src/models/user.py index bfc408c..b87b08e 100644 --- a/backend/src/models/user.py +++ b/backend/src/models/user.py @@ -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"" diff --git a/backend/src/schemas/memo_tree.py b/backend/src/schemas/memo_tree.py new file mode 100644 index 0000000..9c8731b --- /dev/null +++ b/backend/src/schemas/memo_tree.py @@ -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 diff --git a/database/init/005_create_memo_tree_tables.sql b/database/init/005_create_memo_tree_tables.sql new file mode 100644 index 0000000..d7a011f --- /dev/null +++ b/database/init/005_create_memo_tree_tables.sql @@ -0,0 +1,153 @@ +-- 트리 구조 메모장 테이블 생성 +-- 005_create_memo_tree_tables.sql + +-- 메모 트리 (프로젝트/워크스페이스) +CREATE TABLE memo_trees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + tree_type VARCHAR(50) DEFAULT 'general', -- 'novel', 'research', 'project', 'general' + template_data JSONB, -- 템플릿별 메타데이터 + settings JSONB DEFAULT '{}', -- 트리별 설정 + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_public BOOLEAN DEFAULT FALSE, + is_archived BOOLEAN DEFAULT FALSE +); + +-- 메모 노드 (트리의 각 노드) +CREATE TABLE memo_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + parent_id UUID REFERENCES memo_nodes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- 기본 정보 + title VARCHAR(500) NOT NULL, + content TEXT, -- 실제 메모 내용 (Markdown) + node_type VARCHAR(50) DEFAULT 'memo', -- 'folder', 'memo', 'chapter', 'character', 'plot' + + -- 트리 구조 관리 + sort_order INTEGER DEFAULT 0, + depth_level INTEGER DEFAULT 0, + path TEXT, -- 경로 저장 (예: /1/3/7) + + -- 메타데이터 + tags TEXT[], -- 태그 배열 + node_metadata JSONB DEFAULT '{}', -- 노드별 메타데이터 (캐릭터 정보, 플롯 정보 등) + + -- 상태 관리 + status VARCHAR(50) DEFAULT 'draft', -- 'draft', 'writing', 'review', 'complete' + word_count INTEGER DEFAULT 0, + + -- 시간 정보 + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- 제약 조건 + CONSTRAINT no_self_reference CHECK (id != parent_id) +); + +-- 메모 노드 버전 관리 (선택적) +CREATE TABLE memo_node_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id UUID NOT NULL REFERENCES memo_nodes(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + title VARCHAR(500) NOT NULL, + content TEXT, + node_metadata JSONB DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(node_id, version_number) +); + +-- 메모 트리 공유 (협업 기능) +CREATE TABLE memo_tree_shares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tree_id UUID NOT NULL REFERENCES memo_trees(id) ON DELETE CASCADE, + shared_with_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission_level VARCHAR(20) DEFAULT 'read', -- 'read', 'write', 'admin' + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + UNIQUE(tree_id, shared_with_user_id) +); + +-- 인덱스 생성 +CREATE INDEX idx_memo_trees_user_id ON memo_trees(user_id); +CREATE INDEX idx_memo_trees_type ON memo_trees(tree_type); +CREATE INDEX idx_memo_nodes_tree_id ON memo_nodes(tree_id); +CREATE INDEX idx_memo_nodes_parent_id ON memo_nodes(parent_id); +CREATE INDEX idx_memo_nodes_user_id ON memo_nodes(user_id); +CREATE INDEX idx_memo_nodes_path ON memo_nodes USING GIN(string_to_array(path, '/')); +CREATE INDEX idx_memo_nodes_tags ON memo_nodes USING GIN(tags); +CREATE INDEX idx_memo_nodes_type ON memo_nodes(node_type); +CREATE INDEX idx_memo_node_versions_node_id ON memo_node_versions(node_id); +CREATE INDEX idx_memo_tree_shares_tree_id ON memo_tree_shares(tree_id); + +-- 트리거 함수: updated_at 자동 업데이트 +CREATE OR REPLACE FUNCTION update_memo_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 트리거 생성 +CREATE TRIGGER memo_trees_updated_at + BEFORE UPDATE ON memo_trees + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +CREATE TRIGGER memo_nodes_updated_at + BEFORE UPDATE ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_updated_at(); + +-- 트리거 함수: 경로 자동 업데이트 +CREATE OR REPLACE FUNCTION update_memo_node_path() +RETURNS TRIGGER AS $$ +BEGIN + -- 루트 노드인 경우 + IF NEW.parent_id IS NULL THEN + NEW.path = '/' || NEW.id::text; + NEW.depth_level = 0; + ELSE + -- 부모 노드의 경로를 가져와서 확장 + SELECT path || '/' || NEW.id::text, depth_level + 1 + INTO NEW.path, NEW.depth_level + FROM memo_nodes + WHERE id = NEW.parent_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 경로 업데이트 트리거 +CREATE TRIGGER memo_nodes_path_update + BEFORE INSERT OR UPDATE OF parent_id ON memo_nodes + FOR EACH ROW + EXECUTE FUNCTION update_memo_node_path(); + +-- 샘플 데이터 (개발용) +-- 소설 템플릿 예시 +INSERT INTO memo_trees (user_id, title, description, tree_type, template_data) +SELECT + u.id, + '내 첫 번째 소설', + '판타지 소설 프로젝트', + 'novel', + '{ + "genre": "fantasy", + "target_length": 100000, + "chapters_planned": 20, + "main_characters": [], + "world_building": {} + }'::jsonb +FROM users u +WHERE u.email = 'admin@test.com' +LIMIT 1; diff --git a/frontend/hierarchy.html b/frontend/hierarchy.html index 9eb22b7..97c9f00 100644 --- a/frontend/hierarchy.html +++ b/frontend/hierarchy.html @@ -109,8 +109,9 @@

📚 문서 관리 시스템

- 그리드 뷰 - 계층구조 뷰 + 📖 그리드 뷰 + 📚 계층구조 뷰 + 🌳 트리 메모장
diff --git a/frontend/index.html b/frontend/index.html index a0d8bb9..d344f68 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -59,8 +59,9 @@ Document Server
- 그리드 뷰 - 계층구조 뷰 + 📖 그리드 뷰 + 📚 계층구조 뷰 + 🌳 트리 메모장
diff --git a/frontend/memo-tree.html b/frontend/memo-tree.html new file mode 100644 index 0000000..b289f8c --- /dev/null +++ b/frontend/memo-tree.html @@ -0,0 +1,651 @@ + + + + + + 트리 메모장 - Document Server + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ +

트리 메모장

+
+ + + +
+ + +
+ +
+ + + +
+ + +
+ + +
+ + + +
+
+
+
+ + +
+ +
+
+ +
+ + + +
+ + +
+ + + + +
+
+
+ + +
+ +
+ +
+
+ +

트리를 선택하세요

+

왼쪽에서 트리를 선택하거나 새로 만들어보세요

+
+
+ + +
+
+ +

첫 번째 노드를 만들어보세요

+ +
+
+ + +
+ +
+ + + + + + +
+ + +
+
+
+
+ + +
+ + + + +
+
+
+ + +
+
+ +

노드를 선택하세요

+

왼쪽 트리에서 노드를 클릭하여 편집을 시작하세요

+
+
+
+
+ + +
+
+ +

로그인이 필요합니다

+

트리 메모장을 사용하려면 로그인해주세요

+ +
+
+ + +
+
+

새 트리 생성

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

로그인

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ + + + + diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js index 065ff93..8228d22 100644 --- a/frontend/static/js/api.js +++ b/frontend/static/js/api.js @@ -119,7 +119,32 @@ class DocumentServerAPI { // 인증 관련 API async login(email, password) { - return await this.post('/auth/login', { email, password }); + const response = await this.post('/auth/login', { email, password }); + + // 토큰 저장 + if (response.access_token) { + this.setToken(response.access_token); + + // 사용자 정보 가져오기 + try { + const user = await this.getCurrentUser(); + return { + success: true, + user: user, + token: response.access_token + }; + } catch (error) { + return { + success: false, + message: '사용자 정보를 가져올 수 없습니다.' + }; + } + } else { + return { + success: false, + message: '로그인에 실패했습니다.' + }; + } } async logout() { @@ -414,6 +439,73 @@ class DocumentServerAPI { async deleteNote(noteId) { return await this.delete(`/notes/${noteId}`); } + + // ============================================================================ + // 트리 메모장 API + // ============================================================================ + + // 메모 트리 관리 + async getUserMemoTrees(includeArchived = false) { + const params = includeArchived ? '?include_archived=true' : ''; + return await this.get(`/memo-trees/${params}`); + } + + async createMemoTree(treeData) { + return await this.post('/memo-trees/', treeData); + } + + async getMemoTree(treeId) { + return await this.get(`/memo-trees/${treeId}`); + } + + async updateMemoTree(treeId, treeData) { + return await this.put(`/memo-trees/${treeId}`, treeData); + } + + async deleteMemoTree(treeId) { + return await this.delete(`/memo-trees/${treeId}`); + } + + // 메모 노드 관리 + async getMemoTreeNodes(treeId) { + return await this.get(`/memo-trees/${treeId}/nodes`); + } + + async createMemoNode(nodeData) { + return await this.post(`/memo-trees/${nodeData.tree_id}/nodes`, nodeData); + } + + async getMemoNode(nodeId) { + return await this.get(`/memo-trees/nodes/${nodeId}`); + } + + async updateMemoNode(nodeId, nodeData) { + return await this.put(`/memo-trees/nodes/${nodeId}`, nodeData); + } + + async deleteMemoNode(nodeId) { + return await this.delete(`/memo-trees/nodes/${nodeId}`); + } + + // 노드 이동 + async moveMemoNode(nodeId, moveData) { + return await this.put(`/memo-trees/nodes/${nodeId}/move`, moveData); + } + + // 트리 통계 + async getMemoTreeStats(treeId) { + return await this.get(`/memo-trees/${treeId}/stats`); + } + + // 검색 + async searchMemoNodes(searchData) { + return await this.post('/memo-trees/search', searchData); + } + + // 내보내기 + async exportMemoTree(exportData) { + return await this.post('/memo-trees/export', exportData); + } } // 전역 API 인스턴스 diff --git a/frontend/static/js/memo-tree.js b/frontend/static/js/memo-tree.js new file mode 100644 index 0000000..21ee8ec --- /dev/null +++ b/frontend/static/js/memo-tree.js @@ -0,0 +1,984 @@ +/** + * 트리 구조 메모장 JavaScript + */ + +// Monaco Editor 인스턴스 +let monacoEditor = null; + +// 트리 메모장 Alpine.js 컴포넌트 +window.memoTreeApp = function() { + return { + // 상태 관리 + currentUser: null, + userTrees: [], + selectedTreeId: '', + selectedTree: null, + treeNodes: [], + selectedNode: null, + + // UI 상태 + showNewTreeModal: false, + showNewNodeModal: false, + showTreeSettings: false, + showLoginModal: false, + + // 로그인 폼 상태 + loginForm: { + email: '', + password: '' + }, + loginError: '', + loginLoading: false, + + // 폼 데이터 + newTree: { + title: '', + description: '', + tree_type: 'general' + }, + + newNode: { + title: '', + node_type: 'memo', + parent_id: null + }, + + // 에디터 상태 + editorContent: '', + isEditorDirty: false, + + // 트리 상태 + expandedNodes: new Set(), + + // 트리 다이어그램 상태 + treeZoom: 1, + treePanX: 0, + treePanY: 0, + nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑 + isDragging: false, + dragNode: null, + dragOffset: { x: 0, y: 0 }, + + // 초기화 + async init() { + console.log('🌳 트리 메모장 초기화 중...'); + + // API 객체가 로드될 때까지 대기 (더 긴 시간) + let retries = 0; + while ((!window.api || typeof window.api.getUserMemoTrees !== 'function') && retries < 50) { + console.log(`⏳ API 객체 로딩 대기 중... (${retries + 1}/50)`); + await new Promise(resolve => setTimeout(resolve, 100)); + retries++; + } + + if (!window.api || typeof window.api.getUserMemoTrees !== 'function') { + console.error('❌ API 객체 또는 getUserMemoTrees 함수를 로드할 수 없습니다.'); + console.log('현재 window.api:', window.api); + if (window.api) { + console.log('API 객체의 메서드들:', Object.getOwnPropertyNames(window.api)); + } + return; + } + + try { + await this.checkAuthStatus(); + if (this.currentUser) { + await this.loadUserTrees(); + await this.initMonacoEditor(); + } + } catch (error) { + console.error('❌ 초기화 실패:', error); + } + }, + + // 인증 상태 확인 + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.currentUser = user; + console.log('✅ 사용자 인증됨:', user.email); + } catch (error) { + console.log('❌ 인증되지 않음:', error.message); + this.currentUser = null; + + // 토큰이 있지만 만료된 경우 제거 + if (localStorage.getItem('access_token')) { + console.log('🗑️ 만료된 토큰 제거'); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.api.clearToken(); + } + } + }, + + // 사용자 트리 목록 로드 + async loadUserTrees() { + try { + console.log('📊 사용자 트리 목록 로딩...'); + const trees = await window.api.getUserMemoTrees(); + this.userTrees = trees || []; + console.log(`✅ ${this.userTrees.length}개 트리 로드 완료`); + } catch (error) { + console.error('❌ 트리 목록 로드 실패:', error); + this.userTrees = []; + } + }, + + // 트리 로드 + async loadTree(treeId) { + if (!treeId) { + this.selectedTree = null; + this.treeNodes = []; + this.selectedNode = null; + return; + } + + try { + console.log('🌳 트리 로딩:', treeId); + + // 트리 정보 로드 + const tree = this.userTrees.find(t => t.id === treeId); + this.selectedTree = tree; + + // 트리 노드들 로드 + const nodes = await window.api.getMemoTreeNodes(treeId); + this.treeNodes = nodes || []; + + // 첫 번째 노드 선택 (있다면) + if (this.treeNodes.length > 0) { + this.selectNode(this.treeNodes[0]); + } + + // 트리 다이어그램 위치 계산 + this.$nextTick(() => { + setTimeout(() => { + this.calculateNodePositions(); + }, 100); + }); + + console.log(`✅ 트리 로드 완료: ${this.treeNodes.length}개 노드`); + } catch (error) { + console.error('❌ 트리 로드 실패:', error); + alert('트리를 불러오는 중 오류가 발생했습니다.'); + } + }, + + // 트리 생성 + async createTree() { + try { + console.log('🌳 새 트리 생성:', this.newTree); + + const tree = await window.api.createMemoTree(this.newTree); + + // 트리 목록에 추가 + this.userTrees.push(tree); + + // 새 트리 선택 + this.selectedTreeId = tree.id; + await this.loadTree(tree.id); + + // 모달 닫기 및 폼 리셋 + this.showNewTreeModal = false; + this.newTree = { title: '', description: '', tree_type: 'general' }; + + console.log('✅ 트리 생성 완료'); + } catch (error) { + console.error('❌ 트리 생성 실패:', error); + alert('트리 생성 중 오류가 발생했습니다.'); + } + }, + + // 루트 노드 생성 + async createRootNode() { + if (!this.selectedTree) return; + + try { + const nodeData = { + tree_id: this.selectedTree.id, + title: '새 노드', + node_type: 'memo', + parent_id: null + }; + + const node = await window.api.createMemoNode(nodeData); + this.treeNodes.push(node); + this.selectNode(node); + + console.log('✅ 루트 노드 생성 완료'); + } catch (error) { + console.error('❌ 루트 노드 생성 실패:', error); + alert('노드 생성 중 오류가 발생했습니다.'); + } + }, + + // 노드 선택 + selectNode(node) { + // 이전 노드 저장 + if (this.selectedNode && this.isEditorDirty) { + this.saveNode(); + } + + this.selectedNode = node; + + // 에디터에 내용 로드 + if (monacoEditor && node.content) { + monacoEditor.setValue(node.content || ''); + this.isEditorDirty = false; + } + + console.log('📝 노드 선택:', node.title); + }, + + // 노드 저장 + async saveNode() { + if (!this.selectedNode) return; + + try { + // 에디터 내용 가져오기 + if (monacoEditor) { + this.selectedNode.content = monacoEditor.getValue(); + + // 단어 수 계산 (간단한 방식) + const wordCount = this.selectedNode.content + .replace(/\s+/g, ' ') + .trim() + .split(' ') + .filter(word => word.length > 0).length; + this.selectedNode.word_count = wordCount; + } + + await window.api.updateMemoNode(this.selectedNode.id, this.selectedNode); + this.isEditorDirty = false; + + console.log('✅ 노드 저장 완료'); + } catch (error) { + console.error('❌ 노드 저장 실패:', error); + alert('노드 저장 중 오류가 발생했습니다.'); + } + }, + + // 노드 제목 저장 + async saveNodeTitle() { + if (!this.selectedNode) return; + await this.saveNode(); + }, + + // 노드 타입 저장 + async saveNodeType() { + if (!this.selectedNode) return; + await this.saveNode(); + }, + + // 노드 상태 저장 + async saveNodeStatus() { + if (!this.selectedNode) return; + await this.saveNode(); + }, + + // 노드 삭제 + async deleteNode(nodeId) { + if (!confirm('정말로 이 노드를 삭제하시겠습니까?')) return; + + try { + await window.api.deleteMemoNode(nodeId); + + // 트리에서 제거 + this.treeNodes = this.treeNodes.filter(node => node.id !== nodeId); + + // 선택된 노드였다면 선택 해제 + if (this.selectedNode && this.selectedNode.id === nodeId) { + this.selectedNode = null; + if (monacoEditor) { + monacoEditor.setValue(''); + } + } + + console.log('✅ 노드 삭제 완료'); + } catch (error) { + console.error('❌ 노드 삭제 실패:', error); + alert('노드 삭제 중 오류가 발생했습니다.'); + } + }, + + + + // 자식 노드 가져오기 + getChildNodes(parentId) { + return this.treeNodes + .filter(node => node.parent_id === parentId) + .sort((a, b) => a.sort_order - b.sort_order); + }, + + // 루트 노드들 가져오기 + get rootNodes() { + return this.treeNodes + .filter(node => !node.parent_id) + .sort((a, b) => a.sort_order - b.sort_order); + }, + + // 노드 타입별 아이콘 가져오기 + getNodeIcon(nodeType) { + const icons = { + folder: '📁', + memo: '📝', + chapter: '📖', + character: '👤', + plot: '📋' + }; + return icons[nodeType] || '📝'; + }, + + // 상태별 색상 클래스 가져오기 + getStatusColor(status) { + const colors = { + draft: 'text-gray-500', + writing: 'text-yellow-600', + review: 'text-blue-600', + complete: 'text-green-600' + }; + return colors[status] || 'text-gray-700'; + }, + + // 트리 타입별 아이콘 가져오기 + getTreeIcon(treeType) { + const icons = { + novel: '📚', + research: '🔬', + project: '💼', + general: '📂' + }; + return icons[treeType] || '📂'; + }, + + // 빠른 자식 노드 추가 + async addChildNode(parentNode) { + if (!this.selectedTree) return; + + try { + const nodeData = { + tree_id: this.selectedTree.id, + title: '새 노드', + node_type: 'memo', + parent_id: parentNode.id + }; + + const node = await window.api.createMemoNode(nodeData); + this.treeNodes.push(node); + + // 부모 노드 펼치기 + this.expandedNodes.add(parentNode.id); + + // 새 노드 선택 + this.selectNode(node); + + console.log('✅ 자식 노드 생성 완료'); + } catch (error) { + console.error('❌ 자식 노드 생성 실패:', error); + alert('노드 생성 중 오류가 발생했습니다.'); + } + }, + + // 컨텍스트 메뉴 표시 + showContextMenu(event, node) { + // TODO: 우클릭 컨텍스트 메뉴 구현 + console.log('컨텍스트 메뉴:', node.title); + }, + + // 노드 메뉴 표시 + showNodeMenu(event, node) { + // TODO: 노드 옵션 메뉴 구현 + console.log('노드 메뉴:', node.title); + }, + + // 정사 경로 토글 + async toggleCanonical(node) { + try { + const newCanonicalState = !node.is_canonical; + console.log(`🌟 정사 경로 토글: ${node.title} (${newCanonicalState ? '설정' : '해제'})`); + + const updatedData = { + is_canonical: newCanonicalState + }; + + const updatedNode = await window.api.updateMemoNode(node.id, updatedData); + + // 로컬 상태 업데이트 (Alpine.js 반응성 보장) + const nodeIndex = this.treeNodes.findIndex(n => n.id === node.id); + if (nodeIndex !== -1) { + // 배열 전체를 새로 생성하여 Alpine.js 반응성 트리거 + this.treeNodes = this.treeNodes.map(n => + n.id === node.id ? { ...n, ...updatedNode } : n + ); + } + + // 선택된 노드도 업데이트 + if (this.selectedNode && this.selectedNode.id === node.id) { + this.selectedNode = { ...this.selectedNode, ...updatedNode }; + } + + // 강제 리렌더링을 위한 더미 업데이트 + this.treeNodes = [...this.treeNodes]; + + // 트리 다시 그리기 (연결선 업데이트) + this.$nextTick(() => { + this.calculateNodePositions(); + }); + + console.log(`✅ 정사 경로 ${newCanonicalState ? '설정' : '해제'} 완료`); + console.log('📊 업데이트된 노드:', updatedNode); + console.log('🔍 is_canonical 값:', updatedNode.is_canonical); + console.log('🔍 canonical_order 값:', updatedNode.canonical_order); + console.log('🔄 현재 treeNodes 개수:', this.treeNodes.length); + + // 업데이트된 노드 찾기 + const updatedNodeInArray = this.treeNodes.find(n => n.id === node.id); + console.log('🔍 배열 내 업데이트된 노드:', updatedNodeInArray?.is_canonical); + } catch (error) { + console.error('❌ 정사 경로 토글 실패:', error); + alert('정사 경로 설정 중 오류가 발생했습니다.'); + } + }, + + // 노드 드래그 시작 + startDragNode(event, node) { + // 현재는 드래그 기능 비활성화 (패닝과 충돌 방지) + event.stopPropagation(); + console.log('드래그 시작:', node.title); + // TODO: 노드 드래그 앤 드롭 구현 + }, + + // 노드 인라인 편집 + editNodeInline(node) { + // 더블클릭 시 노드 선택하고 에디터로 포커스 + this.selectNode(node); + + // 에디터가 있다면 포커스 + this.$nextTick(() => { + const editorContainer = document.getElementById('editor-container'); + if (editorContainer && this.monacoEditor) { + this.monacoEditor.focus(); + } + }); + + console.log('인라인 편집:', node.title); + }, + + // 노드 위치 계산 및 반환 + getNodePosition(node) { + if (!this.nodePositions.has(node.id)) { + this.calculateNodePositions(); + } + + const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 }; + return `left: ${pos.x}px; top: ${pos.y}px;`; + }, + + // 트리 노드 위치 자동 계산 + calculateNodePositions() { + const canvas = document.getElementById('tree-canvas'); + if (!canvas) return; + + const canvasWidth = canvas.clientWidth; + const canvasHeight = canvas.clientHeight; + + // 노드 크기 설정 + const nodeWidth = 200; + const nodeHeight = 80; + const levelHeight = 150; // 레벨 간 간격 + const nodeSpacing = 50; // 노드 간 간격 + + // 레벨별 노드 그룹화 + const levels = new Map(); + + // 루트 노드들 찾기 + const rootNodes = this.treeNodes.filter(node => !node.parent_id); + + // BFS로 레벨별 노드 배치 + const queue = []; + rootNodes.forEach(node => { + queue.push({ node, level: 0 }); + }); + + while (queue.length > 0) { + const { node, level } = queue.shift(); + + if (!levels.has(level)) { + levels.set(level, []); + } + levels.get(level).push(node); + + // 자식 노드들을 다음 레벨에 추가 + const children = this.getChildNodes(node.id); + children.forEach(child => { + queue.push({ node: child, level: level + 1 }); + }); + } + + // 각 레벨의 노드들 위치 계산 + levels.forEach((nodes, level) => { + const y = 100 + level * levelHeight; // 상단 여백 + 레벨 * 높이 + const totalWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing; + const startX = (canvasWidth - totalWidth) / 2; + + nodes.forEach((node, index) => { + const x = startX + index * (nodeWidth + nodeSpacing); + this.nodePositions.set(node.id, { x, y }); + }); + }); + + // 연결선 다시 그리기 + this.drawConnections(); + }, + + // SVG 연결선 그리기 + drawConnections() { + const svg = document.getElementById('tree-connections'); + if (!svg) return; + + // 기존 연결선 제거 + svg.innerHTML = ''; + + // 각 노드의 자식들과 연결선 그리기 + this.treeNodes.forEach(node => { + const children = this.getChildNodes(node.id); + if (children.length === 0) return; + + const parentPos = this.nodePositions.get(node.id); + if (!parentPos) return; + + children.forEach(child => { + const childPos = this.nodePositions.get(child.id); + if (!childPos) return; + + // 연결선 생성 + const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + + // 부모 노드 하단 중앙에서 시작 + const startX = parentPos.x + 100; // 노드 중앙 + const startY = parentPos.y + 80; // 노드 하단 + + // 자식 노드 상단 중앙으로 연결 + const endX = childPos.x + 100; // 노드 중앙 + const endY = childPos.y; // 노드 상단 + + // 곡선 경로 생성 (베지어 곡선) + const midY = startY + (endY - startY) / 2; + const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`; + + line.setAttribute('d', path); + line.setAttribute('stroke', '#9CA3AF'); + line.setAttribute('stroke-width', '2'); + line.setAttribute('fill', 'none'); + line.setAttribute('marker-end', 'url(#arrowhead)'); + + svg.appendChild(line); + }); + }); + + // 화살표 마커 추가 (한 번만) + if (!svg.querySelector('#arrowhead')) { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + marker.setAttribute('id', 'arrowhead'); + marker.setAttribute('markerWidth', '10'); + marker.setAttribute('markerHeight', '7'); + marker.setAttribute('refX', '9'); + marker.setAttribute('refY', '3.5'); + marker.setAttribute('orient', 'auto'); + + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', '0 0, 10 3.5, 0 7'); + polygon.setAttribute('fill', '#9CA3AF'); + + marker.appendChild(polygon); + defs.appendChild(marker); + svg.appendChild(defs); + } + }, + + // 트리 중앙 정렬 + centerTree() { + this.treePanX = 0; + this.treePanY = 0; + this.treeZoom = 1; + }, + + // 확대 + zoomIn() { + this.treeZoom = Math.min(this.treeZoom * 1.2, 3); + }, + + // 축소 + zoomOut() { + this.treeZoom = Math.max(this.treeZoom / 1.2, 0.3); + }, + + // 휠 이벤트 처리 (확대/축소) + handleWheel(event) { + event.preventDefault(); + if (event.deltaY < 0) { + this.zoomIn(); + } else { + this.zoomOut(); + } + }, + + // 패닝 시작 + startPan(event) { + if (event.target.closest('.tree-diagram-node')) return; // 노드 클릭 시 패닝 방지 + + this.isPanning = true; + this.panStartX = event.clientX - this.treePanX; + this.panStartY = event.clientY - this.treePanY; + + document.addEventListener('mousemove', this.handlePan.bind(this)); + document.addEventListener('mouseup', this.stopPan.bind(this)); + }, + + // 패닝 처리 + handlePan(event) { + if (!this.isPanning) return; + + this.treePanX = event.clientX - this.panStartX; + this.treePanY = event.clientY - this.panStartY; + }, + + // 패닝 종료 + stopPan() { + this.isPanning = false; + document.removeEventListener('mousemove', this.handlePan); + document.removeEventListener('mouseup', this.stopPan); + }, + + // 재귀적 트리 노드 렌더링 + renderTreeNodeRecursive(node, depth, isLast = false, parentPath = []) { + const hasChildren = this.getChildNodes(node.id).length > 0; + const isExpanded = this.expandedNodes.has(node.id); + const isSelected = this.selectedNode && this.selectedNode.id === node.id; + const isRoot = depth === 0; + + // 상태별 색상 + let statusClass = 'text-gray-700'; + switch(node.status) { + case 'draft': statusClass = 'text-gray-500'; break; + case 'writing': statusClass = 'text-yellow-600'; break; + case 'review': statusClass = 'text-blue-600'; break; + case 'complete': statusClass = 'text-green-600'; break; + } + + // 트리 라인 생성 + let treeLines = ''; + for (let i = 0; i < depth; i++) { + if (i < parentPath.length && parentPath[i]) { + // 부모가 마지막이 아니면 세로선 표시 + treeLines += ''; + } else { + // 빈 공간 + treeLines += ''; + } + } + + // 현재 노드의 연결선 + let nodeConnector = ''; + if (!isRoot) { + if (isLast) { + nodeConnector = ''; // └─ + } else { + nodeConnector = ''; // ├─ + } + } + + let html = ` +
+
+ +
+ ${treeLines} + ${nodeConnector} + + + ${hasChildren ? + `` : + '' + } + + + ${this.getNodeIcon(node.node_type)} + + + ${node.title} +
+ + +
+ + +
+ + + ${node.word_count > 0 ? + `${node.word_count}w` : + '' + } +
+ `; + + // 자식 노드들 재귀적으로 렌더링 + if (hasChildren && isExpanded) { + const children = this.getChildNodes(node.id); + const newParentPath = [...parentPath, !isLast]; + + children.forEach((child, index) => { + const isChildLast = index === children.length - 1; + html += this.renderTreeNodeRecursive(child, depth + 1, isChildLast, newParentPath); + }); + } + + html += '
'; + return html; + }, + + // 노드 토글 + toggleNode(nodeId) { + if (this.expandedNodes.has(nodeId)) { + this.expandedNodes.delete(nodeId); + } else { + this.expandedNodes.add(nodeId); + } + // 트리 다시 렌더링을 위해 상태 업데이트 + this.$nextTick(() => { + this.rerenderTree(); + }); + }, + + // 트리 다시 렌더링 + rerenderTree() { + // Alpine.js의 반응성을 트리거하기 위해 배열을 새로 할당 + this.treeNodes = [...this.treeNodes]; + // 강제로 DOM 업데이트 + this.$nextTick(() => { + // 트리 컨테이너 찾아서 다시 렌더링 + const treeContainer = document.querySelector('.tree-container'); + if (treeContainer) { + // Alpine.js가 자동으로 다시 렌더링함 + } + }); + }, + + // 모두 펼치기 + expandAll() { + this.treeNodes.forEach(node => { + if (this.getChildNodes(node.id).length > 0) { + this.expandedNodes.add(node.id); + } + }); + this.rerenderTree(); + }, + + // 모두 접기 + collapseAll() { + this.expandedNodes.clear(); + this.rerenderTree(); + }, + + // Monaco Editor 초기화 + async initMonacoEditor() { + console.log('🎨 Monaco Editor 초기화 시작...'); + + // Monaco Editor 로더가 로드될 때까지 대기 + let retries = 0; + while (typeof require === 'undefined' && retries < 30) { + console.log(`⏳ Monaco Editor 로더 대기 중... (${retries + 1}/30)`); + await new Promise(resolve => setTimeout(resolve, 200)); + retries++; + } + + if (typeof require === 'undefined') { + console.warn('❌ Monaco Editor 로더를 찾을 수 없습니다. 기본 textarea를 사용합니다.'); + this.setupFallbackEditor(); + return; + } + + try { + require.config({ + paths: { + vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs' + } + }); + + require(['vs/editor/editor.main'], () => { + // 에디터 컨테이너가 생성될 때까지 대기 + const waitForEditor = () => { + const editorElement = document.getElementById('monaco-editor'); + if (!editorElement) { + console.log('⏳ Monaco Editor 컨테이너 대기 중...'); + setTimeout(waitForEditor, 100); + return; + } + + // 이미 Monaco Editor가 생성되어 있다면 제거 + if (monacoEditor) { + monacoEditor.dispose(); + monacoEditor = null; + } + + monacoEditor = monaco.editor.create(editorElement, { + value: '', + language: 'markdown', + theme: 'vs-light', + automaticLayout: true, + wordWrap: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 14, + lineNumbers: 'on', + folding: true, + renderWhitespace: 'boundary' + }); + + // 내용 변경 감지 + monacoEditor.onDidChangeModelContent(() => { + this.isEditorDirty = true; + }); + + console.log('✅ Monaco Editor 초기화 완료'); + }; + + // 에디터 컨테이너 대기 시작 + waitForEditor(); + }); + } catch (error) { + console.error('❌ Monaco Editor 초기화 실패:', error); + this.setupFallbackEditor(); + } + }, + + // 폴백 에디터 설정 (Monaco가 실패했을 때) + setupFallbackEditor() { + console.log('📝 폴백 textarea 에디터 설정 중...'); + const editorElement = document.getElementById('monaco-editor'); + if (editorElement) { + editorElement.innerHTML = ` + + `; + + const textarea = document.getElementById('fallback-editor'); + if (textarea) { + textarea.addEventListener('input', () => { + this.isEditorDirty = true; + }); + console.log('✅ 폴백 에디터 설정 완료'); + } + } + }, + + // 로그인 모달 열기 + openLoginModal() { + this.showLoginModal = true; + this.loginForm = { email: '', password: '' }; + this.loginError = ''; + }, + + // 로그인 처리 + async handleLogin() { + this.loginLoading = true; + this.loginError = ''; + + try { + const response = await window.api.login(this.loginForm.email, this.loginForm.password); + + if (response.success) { + this.currentUser = response.user; + this.showLoginModal = false; + + // 트리 목록 다시 로드 + await this.loadUserTrees(); + } else { + this.loginError = response.message || '로그인에 실패했습니다.'; + } + } catch (error) { + console.error('로그인 오류:', error); + this.loginError = '로그인 중 오류가 발생했습니다.'; + } finally { + this.loginLoading = false; + } + }, + + // 로그아웃 + async logout() { + try { + await window.api.logout(); + this.currentUser = null; + this.userTrees = []; + this.selectedTree = null; + this.treeNodes = []; + this.selectedNode = null; + console.log('✅ 로그아웃 완료'); + } catch (error) { + console.error('❌ 로그아웃 실패:', error); + } + } + }; +}; + +// 전역 인스턴스 등록 (HTML에서 접근하기 위해) +window.memoTreeInstance = null; + +// Alpine.js 초기화 후 전역 인스턴스 설정 +document.addEventListener('alpine:init', () => { + // 페이지 로드 후 인스턴스 등록 + setTimeout(() => { + const appElement = document.querySelector('[x-data="memoTreeApp()"]'); + if (appElement && appElement._x_dataStack) { + window.memoTreeInstance = appElement._x_dataStack[0]; + } + }, 100); +}); + +// 트리 노드 컴포넌트 +window.treeNodeComponent = function(node) { + return { + node: node, + expanded: true, + + get hasChildren() { + return window.memoTreeInstance?.getChildNodes(this.node.id).length > 0; + }, + + toggleExpanded() { + this.expanded = !this.expanded; + if (this.expanded) { + window.memoTreeInstance?.expandedNodes.add(this.node.id); + } else { + window.memoTreeInstance?.expandedNodes.delete(this.node.id); + } + } + }; +}; + +console.log('🌳 트리 메모장 JavaScript 로드 완료'); diff --git a/frontend/static/js/story-view.js b/frontend/static/js/story-view.js new file mode 100644 index 0000000..dbb2a30 --- /dev/null +++ b/frontend/static/js/story-view.js @@ -0,0 +1,345 @@ +// story-view.js - 정사 경로 스토리 뷰 컴포넌트 + +console.log('📖 스토리 뷰 JavaScript 로드 완료'); + +// Alpine.js 컴포넌트 +window.storyViewApp = function() { + return { + // 사용자 상태 + currentUser: null, + + // 트리 데이터 + userTrees: [], + selectedTreeId: '', + selectedTree: null, + canonicalNodes: [], // 정사 경로 노드들만 + + // UI 상태 + viewMode: 'toc', // 'toc' | 'full' + showLoginModal: false, + showEditModal: false, + editingNode: null, + + // 로그인 폼 상태 + loginForm: { + email: '', + password: '' + }, + loginError: '', + loginLoading: false, + + // 계산된 속성들 + get totalWords() { + return this.canonicalNodes.reduce((sum, node) => sum + (node.word_count || 0), 0); + }, + + // 초기화 + async init() { + console.log('📖 스토리 뷰 초기화 중...'); + + // API 로드 대기 + let retryCount = 0; + while (!window.api && retryCount < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + retryCount++; + } + + if (!window.api) { + console.error('❌ API가 로드되지 않았습니다.'); + return; + } + + // 사용자 인증 상태 확인 + await this.checkAuthStatus(); + + // 인증된 경우 트리 목록 로드 + if (this.currentUser) { + await this.loadUserTrees(); + } + }, + + // 인증 상태 확인 + async checkAuthStatus() { + try { + const user = await window.api.getCurrentUser(); + this.currentUser = user; + console.log('✅ 사용자 인증됨:', user.email); + } catch (error) { + console.log('❌ 인증되지 않음:', error.message); + this.currentUser = null; + + // 만료된 토큰 정리 + localStorage.removeItem('token'); + } + }, + + // 사용자 트리 목록 로드 + async loadUserTrees() { + try { + console.log('📊 사용자 트리 목록 로딩...'); + const trees = await window.api.getUserMemoTrees(); + this.userTrees = trees || []; + console.log(`✅ ${this.userTrees.length}개 트리 로드 완료`); + } catch (error) { + console.error('❌ 트리 목록 로드 실패:', error); + this.userTrees = []; + } + }, + + // 스토리 로드 (정사 경로만) + async loadStory(treeId) { + if (!treeId) { + this.selectedTree = null; + this.canonicalNodes = []; + return; + } + + try { + console.log('📖 스토리 로딩:', treeId); + + // 트리 정보 로드 + this.selectedTree = await window.api.getMemoTree(treeId); + + // 모든 노드 로드 + const allNodes = await window.api.getMemoTreeNodes(treeId); + + // 정사 경로 노드들만 필터링하고 순서대로 정렬 + this.canonicalNodes = allNodes + .filter(node => node.is_canonical) + .sort((a, b) => (a.canonical_order || 0) - (b.canonical_order || 0)); + + console.log(`✅ 스토리 로드 완료: ${this.canonicalNodes.length}개 정사 노드`); + } catch (error) { + console.error('❌ 스토리 로드 실패:', error); + alert('스토리를 불러오는 중 오류가 발생했습니다.'); + } + }, + + // 뷰 모드 토글 + toggleView() { + this.viewMode = this.viewMode === 'toc' ? 'full' : 'toc'; + }, + + // 챕터로 스크롤 + scrollToChapter(nodeId) { + if (this.viewMode === 'toc') { + this.viewMode = 'full'; + // DOM 업데이트 대기 후 스크롤 + this.$nextTick(() => { + const element = document.getElementById(`chapter-${nodeId}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + } else { + const element = document.getElementById(`chapter-${nodeId}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + }, + + // 챕터 편집 (인라인 모달) + editChapter(node) { + this.editingNode = { ...node }; // 복사본 생성 + this.showEditModal = true; + }, + + // 편집 취소 + cancelEdit() { + this.showEditModal = false; + this.editingNode = null; + }, + + // 편집 저장 + async saveEdit() { + if (!this.editingNode) return; + + try { + console.log('💾 챕터 저장 중:', this.editingNode.title); + + const updatedNode = await window.api.updateMemoNode(this.editingNode.id, { + title: this.editingNode.title, + content: this.editingNode.content + }); + + // 로컬 상태 업데이트 + const nodeIndex = this.canonicalNodes.findIndex(n => n.id === this.editingNode.id); + if (nodeIndex !== -1) { + this.canonicalNodes = this.canonicalNodes.map(n => + n.id === this.editingNode.id ? { ...n, ...updatedNode } : n + ); + } + + this.showEditModal = false; + this.editingNode = null; + + console.log('✅ 챕터 저장 완료'); + } catch (error) { + console.error('❌ 챕터 저장 실패:', error); + alert('챕터 저장 중 오류가 발생했습니다.'); + } + }, + + // 스토리 내보내기 + async exportStory() { + if (!this.selectedTree || this.canonicalNodes.length === 0) { + alert('내보낼 스토리가 없습니다.'); + return; + } + + try { + // 텍스트 형태로 스토리 생성 + let storyText = `${this.selectedTree.title}\n`; + storyText += `${'='.repeat(this.selectedTree.title.length)}\n\n`; + + if (this.selectedTree.description) { + storyText += `${this.selectedTree.description}\n\n`; + } + + storyText += `작성일: ${this.formatDate(this.selectedTree.created_at)}\n`; + storyText += `수정일: ${this.formatDate(this.selectedTree.updated_at)}\n`; + storyText += `총 ${this.canonicalNodes.length}개 챕터, ${this.totalWords}단어\n\n`; + storyText += `${'='.repeat(50)}\n\n`; + + this.canonicalNodes.forEach((node, index) => { + storyText += `${index + 1}. ${node.title}\n`; + storyText += `${'-'.repeat(node.title.length + 3)}\n\n`; + + if (node.content) { + // HTML 태그 제거 + const plainText = node.content.replace(/<[^>]*>/g, ''); + storyText += `${plainText}\n\n`; + } else { + storyText += `[이 챕터는 아직 내용이 없습니다]\n\n`; + } + + storyText += `${'─'.repeat(30)}\n\n`; + }); + + // 파일 다운로드 + const blob = new Blob([storyText], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.selectedTree.title}_정사스토리.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + console.log('✅ 스토리 내보내기 완료'); + } catch (error) { + console.error('❌ 스토리 내보내기 실패:', error); + alert('스토리 내보내기 중 오류가 발생했습니다.'); + } + }, + + // 스토리 인쇄 + printStory() { + if (!this.selectedTree || this.canonicalNodes.length === 0) { + alert('인쇄할 스토리가 없습니다.'); + return; + } + + // 전체 뷰로 변경 후 인쇄 + if (this.viewMode === 'toc') { + this.viewMode = 'full'; + this.$nextTick(() => { + window.print(); + }); + } else { + window.print(); + } + }, + + // 유틸리티 함수들 + formatDate(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + + formatContent(content) { + if (!content) return ''; + + // 간단한 마크다운 스타일 변환 + return content + .replace(/\n\n/g, '

') + .replace(/\n/g, '
') + .replace(/^/, '

') + .replace(/$/, '

'); + }, + + getNodeTypeLabel(nodeType) { + const labels = { + 'folder': '📁 폴더', + 'memo': '📝 메모', + 'chapter': '📖 챕터', + 'character': '👤 인물', + 'plot': '📋 플롯' + }; + return labels[nodeType] || '📝 메모'; + }, + + getStatusLabel(status) { + const labels = { + 'draft': '📝 초안', + 'writing': '✍️ 작성중', + 'review': '👀 검토중', + 'complete': '✅ 완료' + }; + return labels[status] || '📝 초안'; + }, + + // 로그인 관련 함수들 + openLoginModal() { + this.showLoginModal = true; + this.loginForm = { email: '', password: '' }; + this.loginError = ''; + }, + + async handleLogin() { + this.loginLoading = true; + this.loginError = ''; + + try { + const response = await window.api.login(this.loginForm.email, this.loginForm.password); + + if (response.success) { + this.currentUser = response.user; + this.showLoginModal = false; + + // 트리 목록 다시 로드 + await this.loadUserTrees(); + } else { + this.loginError = response.message || '로그인에 실패했습니다.'; + } + } catch (error) { + console.error('로그인 오류:', error); + this.loginError = '로그인 중 오류가 발생했습니다.'; + } finally { + this.loginLoading = false; + } + }, + + async logout() { + try { + await window.api.logout(); + this.currentUser = null; + this.userTrees = []; + this.selectedTree = null; + this.canonicalNodes = []; + console.log('✅ 로그아웃 완료'); + } catch (error) { + console.error('❌ 로그아웃 실패:', error); + } + } + }; +}; + +console.log('📖 스토리 뷰 컴포넌트 등록 완료'); diff --git a/frontend/story-view.html b/frontend/story-view.html new file mode 100644 index 0000000..723b07d --- /dev/null +++ b/frontend/story-view.html @@ -0,0 +1,389 @@ + + + + + + 스토리 뷰 - 정사 경로 + + + + + + +
+
+
+ + + + +
+
+ + +
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + +
+
+ + +
+ + + +
+
+
+ + +
+ +

스토리를 선택하세요

+

위에서 트리를 선택하면 정사 경로가 목차 형태로 표시됩니다

+
+ + +
+ +

정사 경로가 없습니다

+

트리 에디터에서 노드들을 정사로 설정해주세요

+ + 트리 에디터로 가기 + +
+ + +
+ + +
+

+

+
+ + + + +
+
+ + +
+
+

+ 목차 (정사 경로) +

+
+
+
+ +
+
+
+ + +
+
+

+ 전체 스토리 +

+
+
+ +
+
+
+
+
+ + +
+
+ +

로그인이 필요합니다

+

스토리를 보려면 먼저 로그인해주세요

+ +
+
+ + +
+
+
+
+

챕터 편집

+ +
+
+ +
+
+ +
+ + +
+ + +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+

로그인

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+
+ + + + +