diff --git a/app/api/library.py b/app/api/library.py new file mode 100644 index 0000000..781e24e --- /dev/null +++ b/app/api/library.py @@ -0,0 +1,334 @@ +"""자료실 분류 체계 CRUD API — /api/library""" + +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy import text as sql_text +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from core.database import get_session +from core.library import LIBRARY_PREFIX, MAX_DEPTH, normalize_library_path +from models.category import LibraryCategory +from models.document import Document +from models.user import User + +router = APIRouter() + + +# ─── 스키마 ─── + + +class CategoryCreate(BaseModel): + path: str + + +class CategoryRename(BaseModel): + path: str + new_name: str + + +class CategoryResponse(BaseModel): + id: int + path: str + name: str + parent_path: str | None + depth: int + is_system: bool + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class CategoryTreeNode(BaseModel): + name: str + path: str + count: int + is_category: bool + is_system: bool + has_children: bool + children: list["CategoryTreeNode"] + + +# ─── 엔드포인트 ─── + + +@router.get("/categories", response_model=list[CategoryResponse]) +async def list_categories( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """전체 카테고리 flat 목록 (path 순)""" + result = await session.execute( + select(LibraryCategory).order_by(LibraryCategory.path) + ) + return [CategoryResponse.model_validate(c) for c in result.scalars().all()] + + +@router.post("/categories", response_model=CategoryResponse, status_code=201) +async def create_category( + body: CategoryCreate, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """카테고리 생성 (조상 자동 생성 포함)""" + try: + normalized = normalize_library_path(body.path) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + segments = normalized.split("/") + if len(segments) > MAX_DEPTH: + raise HTTPException(status_code=400, detail=f"최대 {MAX_DEPTH}단계까지 가능") + + # 중복 검사 + existing = await session.execute( + select(LibraryCategory).where(LibraryCategory.path == normalized) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="이미 존재하는 분류 경로") + + # 조상 자동 생성 + for i in range(1, len(segments)): + ancestor_path = "/".join(segments[:i]) + ancestor_name = segments[i - 1] + ancestor_parent = "/".join(segments[: i - 1]) or None + exists = await session.execute( + select(LibraryCategory.id).where( + LibraryCategory.path == ancestor_path + ) + ) + if not exists.scalar_one_or_none(): + session.add(LibraryCategory( + path=ancestor_path, + name=ancestor_name, + parent_path=ancestor_parent, + depth=i, + )) + + # 본 카테고리 생성 + category = LibraryCategory( + path=normalized, + name=segments[-1], + parent_path="/".join(segments[:-1]) or None, + depth=len(segments), + ) + session.add(category) + await session.commit() + await session.refresh(category) + + return CategoryResponse.model_validate(category) + + +@router.patch("/categories", response_model=CategoryResponse) +async def rename_category( + body: CategoryRename, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """카테고리 이름 변경 (leaf only, path 기반 식별)""" + # 카테고리 조회 + result = await session.execute( + select(LibraryCategory).where(LibraryCategory.path == body.path) + ) + category = result.scalar_one_or_none() + if not category: + raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다") + + # 시스템 분류 보호 + if category.is_system: + raise HTTPException(status_code=422, detail="시스템 분류는 변경할 수 없습니다") + + # leaf 검사 + children = await session.execute( + select(func.count()).where( + LibraryCategory.parent_path == category.path + ) + ) + if children.scalar() > 0: + raise HTTPException( + status_code=422, detail="하위 분류가 있어 이름을 변경할 수 없습니다" + ) + + # new_name 검증 + new_name = body.new_name.strip() + if not new_name: + raise HTTPException(status_code=400, detail="빈 이름") + if len(new_name) > 30: + raise HTTPException(status_code=400, detail="이름은 30자 이하") + + # 새 path 계산 + new_path = ( + f"{category.parent_path}/{new_name}" if category.parent_path else new_name + ) + + # 중복 검사 + dup = await session.execute( + select(LibraryCategory.id).where(LibraryCategory.path == new_path) + ) + if dup.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="같은 이름의 분류가 이미 존재합니다") + + old_tag = f"{LIBRARY_PREFIX}{category.path}" + new_tag = f"{LIBRARY_PREFIX}{new_path}" + + # 문서 태그 갱신 + await session.execute( + sql_text(""" + UPDATE documents + SET user_tags = COALESCE(( + SELECT jsonb_agg( + CASE WHEN elem = :old_tag THEN :new_tag ELSE elem END + ) + FROM jsonb_array_elements_text( + COALESCE(user_tags, '[]'::jsonb) + ) AS elem + ), '[]'::jsonb) + WHERE user_tags @> :old_tag_jsonb + """).bindparams( + old_tag=old_tag, + new_tag=new_tag, + old_tag_jsonb=f'["{old_tag}"]', + ) + ) + + # 카테고리 row 갱신 (path, name만. parent_path 유지) + category.path = new_path + category.name = new_name + await session.commit() + await session.refresh(category) + + return CategoryResponse.model_validate(category) + + +@router.delete("/categories", status_code=204) +async def delete_category( + path: str = Query(..., description="삭제할 카테고리 경로"), + user: Annotated[User, Depends(get_current_user)] = None, + session: Annotated[AsyncSession, Depends(get_session)] = None, +): + """카테고리 삭제 (leaf only, 문서 없는 경우만)""" + result = await session.execute( + select(LibraryCategory).where(LibraryCategory.path == path) + ) + category = result.scalar_one_or_none() + if not category: + raise HTTPException(status_code=404, detail="분류를 찾을 수 없습니다") + + if category.is_system: + raise HTTPException(status_code=422, detail="시스템 분류는 삭제할 수 없습니다") + + # leaf 검사 + children = await session.execute( + select(func.count()).where( + LibraryCategory.parent_path == category.path + ) + ) + if children.scalar() > 0: + raise HTTPException( + status_code=422, detail="하위 분류가 있어 삭제할 수 없습니다" + ) + + # 문서 연결 검사 + tag = f"{LIBRARY_PREFIX}{category.path}" + doc_count = await session.execute( + sql_text(""" + SELECT COUNT(*) FROM documents + WHERE deleted_at IS NULL + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements_text( + COALESCE(user_tags, '[]'::jsonb) + ) AS t + WHERE t = :tag + ) + """).bindparams(tag=tag) + ) + if doc_count.scalar() > 0: + raise HTTPException( + status_code=422, + detail="이 분류에 속한 문서가 있어 삭제할 수 없습니다. 문서를 먼저 이동하세요.", + ) + + await session.delete(category) + await session.commit() + + +@router.get("/tree", response_model=list[CategoryTreeNode]) +async def get_library_tree( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """카테고리 저장소 + 문서 태그 count 머지 트리""" + + # 1. 카테고리 전체 fetch + cat_result = await session.execute( + select(LibraryCategory).order_by(LibraryCategory.path) + ) + categories = cat_result.scalars().all() + + # path → category 매핑 + cat_map: dict[str, LibraryCategory] = {c.path: c for c in categories} + + # 2. 문서 태그에서 doc count 집계 + doc_result = await session.execute( + select(Document.id, Document.user_tags).where( + Document.deleted_at == None, # noqa: E711 + Document.user_tags != None, # noqa: E711 + ) + ) + + # path → set of doc_ids + path_docs: dict[str, set[int]] = {} + for doc_id, tags in doc_result: + if not tags: + continue + seen_ancestors: set[str] = set() + for tag in tags: + if not isinstance(tag, str) or not tag.startswith(LIBRARY_PREFIX): + continue + path = tag[len(LIBRARY_PREFIX):] + parts = path.split("/") + for i in range(1, len(parts) + 1): + ancestor = "/".join(parts[:i]) + if ancestor not in seen_ancestors: + path_docs.setdefault(ancestor, set()).add(doc_id) + seen_ancestors.add(ancestor) + + # 3. 모든 path 합산 (카테고리 + 태그) + all_paths = set(cat_map.keys()) | set(path_docs.keys()) + + # 4. 트리 구축 + root: dict = {} + for p in sorted(all_paths): + parts = p.split("/") + node = root + for i, part in enumerate(parts): + if part not in node: + node[part] = {"_children": {}} + node = node[part]["_children"] if i < len(parts) - 1 else node[part] + + def build_tree(d: dict, prefix: str = "") -> list[dict]: + nodes = [] + for name, data in sorted(d.items()): + if name.startswith("_"): + continue + path = f"{prefix}/{name}" if prefix else name + children_dict = data.get("_children", {}) + children = build_tree(children_dict, path) + cat = cat_map.get(path) + nodes.append(CategoryTreeNode( + name=name, + path=path, + count=len(path_docs.get(path, set())), + is_category=path in cat_map, + is_system=cat.is_system if cat else False, + has_children=len(children) > 0, + children=children, + )) + return nodes + + return build_tree(root) diff --git a/app/main.py b/app/main.py index fe40f9f..4980042 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ from api.auth import router as auth_router from api.dashboard import router as dashboard_router from api.digest import router as digest_router from api.documents import router as documents_router +from api.library import router as library_router from api.memos import router as memos_router from api.news import router as news_router from api.search import router as search_router @@ -92,6 +93,7 @@ app.include_router(search_router, prefix="/api/search", tags=["search"]) app.include_router(memos_router, prefix="/api/memos", tags=["memos"]) app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"]) +app.include_router(library_router, prefix="/api/library", tags=["library"]) app.include_router(news_router, prefix="/api/news", tags=["news"]) app.include_router(digest_router, prefix="/api/digest", tags=["digest"]) diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..14716c5 --- /dev/null +++ b/app/models/category.py @@ -0,0 +1,25 @@ +"""library_categories 테이블 ORM — 자료실 분류 체계 독립 관리""" + +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column + +from core.database import Base + + +class LibraryCategory(Base): + __tablename__ = "library_categories" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + path: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + name: Mapped[str] = mapped_column(Text, nullable=False) + parent_path: Mapped[str | None] = mapped_column(Text, nullable=True) + depth: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + is_system: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now, onupdate=datetime.now + ) diff --git a/frontend/src/lib/components/editors/LibraryPathEditor.svelte b/frontend/src/lib/components/editors/LibraryPathEditor.svelte index ffcf8fe..e155d94 100644 --- a/frontend/src/lib/components/editors/LibraryPathEditor.svelte +++ b/frontend/src/lib/components/editors/LibraryPathEditor.svelte @@ -19,6 +19,8 @@ let editing = $state(false); let existingPaths = $state([]); let showSuggestions = $state(false); + let showCategoryPicker = $state(false); + let categoryFilter = $state(''); let suggestions = $derived( newPath.length >= 1 @@ -30,6 +32,14 @@ : [] ); + let filteredCategories = $derived( + categoryFilter + ? existingPaths.filter( + (p) => p.toLowerCase().includes(categoryFilter.toLowerCase()) && !libraryPaths.includes(p) + ) + : existingPaths.filter((p) => !libraryPaths.includes(p)) + ); + // 입력 정규화 미리보기 let normalizedPreview = $derived(() => { try { @@ -48,23 +58,13 @@ onMount(async () => { try { - const tree = await api('/documents/library-tree'); - existingPaths = flattenPaths(tree); + const categories = await api('/library/categories'); + existingPaths = categories.map((c) => c.path); } catch { /* 자동완성 실패해도 입력은 가능 */ } }); - function flattenPaths(nodes, prefix = '') { - let paths = []; - for (const n of nodes) { - const p = prefix ? `${prefix}/${n.name}` : n.name; - paths.push(p); - if (n.children?.length) paths.push(...flattenPaths(n.children, p)); - } - return paths; - } - function normalizePath(raw) { const stripped = raw.trim().replace(/^\/+|\/+$/g, ''); if (!stripped) throw new Error('빈 경로'); @@ -205,12 +205,45 @@ {/if} {:else} - +
+ + +
+ {/if} + + + {#if showCategoryPicker} +
+ + {#if filteredCategories.length === 0} +

일치하는 분류 없음

+ {:else} + {#each filteredCategories as path} + + {/each} + {/if} +
{/if} diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index afd35a9..7e1a8f4 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -1,10 +1,11 @@