diff --git a/app/api/documents.py b/app/api/documents.py
index 13ff4a9..315d643 100644
--- a/app/api/documents.py
+++ b/app/api/documents.py
@@ -4,6 +4,7 @@ import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated
+from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
@@ -74,6 +75,7 @@ class DocumentUpdate(BaseModel):
ai_domain: str | None = None
ai_sub_group: str | None = None
ai_tags: list | None = None
+ user_tags: list | None = None
user_note: str | None = None
is_read: bool | None = None
edit_url: str | None = None
@@ -141,6 +143,132 @@ async def get_document_tree(
return build_tree(root)
+@router.get("/library-tree")
+async def get_library_tree(
+ user: Annotated[User, Depends(get_current_user)],
+ session: Annotated[AsyncSession, Depends(get_session)],
+):
+ """자료실 트리 (user_tags @library/ 경로 기반, unique doc count)"""
+ from core.library import LIBRARY_PREFIX
+
+ result = await session.execute(
+ select(Document.id, Document.user_tags).where(
+ Document.deleted_at == None, # noqa: E711
+ Document.user_tags != None, # noqa: E711
+ )
+ )
+
+ root: dict = {}
+ for doc_id, tags in 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("/")
+ node = root
+ for i, part in enumerate(parts):
+ if part not in node:
+ node[part] = {"_docs": set(), "_children": {}}
+ ancestor_key = "/".join(parts[: i + 1])
+ if ancestor_key not in seen_ancestors:
+ node[part]["_docs"].add(doc_id)
+ seen_ancestors.add(ancestor_key)
+ node = node[part]["_children"]
+
+ def build_library_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 = build_library_tree(data["_children"], path)
+ nodes.append({
+ "name": name,
+ "path": path,
+ "count": len(data["_docs"]),
+ "children": children,
+ })
+ return nodes
+
+ return build_library_tree(root)
+
+
+@router.get("/library", response_model=DocumentListResponse)
+async def list_library_documents(
+ user: Annotated[User, Depends(get_current_user)],
+ session: Annotated[AsyncSession, Depends(get_session)],
+ path: str | None = None,
+ q: str | None = None,
+ sort: str = Query("updated_desc"),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+):
+ """자료실 문서 목록 (prefix match, title 검색, 정렬)"""
+ from sqlalchemy import text as sql_text
+
+ from core.library import LIBRARY_PREFIX, normalize_library_path
+
+ # path 쿼리 정규화 (PATCH와 동일 semantics)
+ if path:
+ try:
+ path = normalize_library_path(path)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ query = select(Document).where(
+ Document.deleted_at == None, # noqa: E711
+ )
+
+ if path:
+ exact = f"{LIBRARY_PREFIX}{path}"
+ prefix = f"{LIBRARY_PREFIX}{path}/%"
+ query = query.where(
+ sql_text("""
+ EXISTS (
+ SELECT 1 FROM jsonb_array_elements_text(documents.user_tags) AS t
+ WHERE t = :exact OR t LIKE :prefix
+ )
+ """).bindparams(exact=exact, prefix=prefix)
+ )
+ else:
+ query = query.where(
+ sql_text("""
+ EXISTS (
+ SELECT 1 FROM jsonb_array_elements_text(documents.user_tags) AS t
+ WHERE t LIKE '@library/%'
+ )
+ """)
+ )
+
+ if q:
+ query = query.where(Document.title.ilike(f"%{q}%"))
+
+ # 전체 건수
+ count_query = select(func.count()).select_from(query.subquery())
+ total = (await session.execute(count_query)).scalar()
+
+ # 정렬
+ sort_map = {
+ "updated_desc": Document.updated_at.desc(),
+ "title_asc": Document.title.asc(),
+ "created_desc": Document.created_at.desc(),
+ }
+ query = query.order_by(sort_map.get(sort, Document.updated_at.desc()))
+ query = query.offset((page - 1) * page_size).limit(page_size)
+ result = await session.execute(query)
+ items = result.scalars().all()
+
+ return DocumentListResponse(
+ items=[DocumentResponse.model_validate(doc) for doc in items],
+ total=total,
+ page=page,
+ page_size=page_size,
+ )
+
+
@router.get("/", response_model=DocumentListResponse)
async def list_documents(
user: Annotated[User, Depends(get_current_user)],
@@ -206,6 +334,7 @@ async def get_document_file(
doc_id: int,
session: Annotated[AsyncSession, Depends(get_session)],
token: str | None = Query(None, description="Bearer token (iframe용)"),
+ download: bool = Query(False, description="true면 attachment (브라우저 다운로드)"),
user: User | None = Depends(lambda: None),
):
"""문서 원본 파일 서빙 (Bearer 헤더 또는 ?token= 쿼리 파라미터)"""
@@ -246,10 +375,19 @@ async def get_document_file(
suffix = file_path.suffix.lower()
media_type = media_types.get(suffix, "application/octet-stream")
+ # Content-Disposition: download=true면 attachment (한글 filename* 호환)
+ if download:
+ raw_title = doc.title or f"document-{doc_id}"
+ ascii_fallback = raw_title.encode("ascii", "replace").decode()
+ utf8_encoded = quote(f"{raw_title}{suffix}")
+ disposition = f'attachment; filename="{ascii_fallback}{suffix}"; filename*=UTF-8\'\'{utf8_encoded}'
+ else:
+ disposition = "inline"
+
return FileResponse(
path=str(file_path),
media_type=media_type,
- headers={"Content-Disposition": "inline"},
+ headers={"Content-Disposition": disposition},
)
@@ -324,11 +462,21 @@ async def update_document(
session: Annotated[AsyncSession, Depends(get_session)],
):
"""문서 메타데이터 수정 (수동 오버라이드)"""
+ from core.library import validate_user_tags
+
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
update_data = body.model_dump(exclude_unset=True)
+
+ # user_tags 검증: @library/ 경로 정규화 + 타입/중복 체크
+ if "user_tags" in update_data and update_data["user_tags"] is not None:
+ try:
+ update_data["user_tags"] = validate_user_tags(update_data["user_tags"])
+ except (TypeError, ValueError) as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
for field, value in update_data.items():
setattr(doc, field, value)
doc.updated_at = datetime.now(timezone.utc)
@@ -375,6 +523,7 @@ async def get_document_preview(
doc_id: int,
session: Annotated[AsyncSession, Depends(get_session)],
token: str | None = Query(None, description="Bearer token (iframe용)"),
+ download: bool = Query(False, description="true면 attachment (PDF 다운로드)"),
):
"""PDF 미리보기 캐시 서빙"""
from core.auth import decode_token
@@ -394,10 +543,18 @@ async def get_document_preview(
if not preview_path.exists():
raise HTTPException(status_code=404, detail="미리보기가 아직 생성되지 않았습니다")
+ if download:
+ raw_title = doc.title or f"document-{doc_id}"
+ ascii_fallback = raw_title.encode("ascii", "replace").decode()
+ utf8_encoded = quote(f"{raw_title}.pdf")
+ disposition = f'attachment; filename="{ascii_fallback}.pdf"; filename*=UTF-8\'\'{utf8_encoded}'
+ else:
+ disposition = "inline"
+
return FileResponse(
path=str(preview_path),
media_type="application/pdf",
- headers={"Content-Disposition": "inline"},
+ headers={"Content-Disposition": disposition},
)
diff --git a/app/core/library.py b/app/core/library.py
new file mode 100644
index 0000000..5d32dda
--- /dev/null
+++ b/app/core/library.py
@@ -0,0 +1,79 @@
+"""자료실 경로 유틸.
+
+user_tags 내 @library/ 접두사 태그를 정규화·검증·추출한다.
+"""
+
+LIBRARY_PREFIX = "@library/"
+MAX_DEPTH = 5
+MAX_SEGMENT_LEN = 30
+
+
+def normalize_library_path(raw: str) -> str:
+ """경로 정규화. 엄격 정책 — 규칙 위반 시 ValueError 즉시 raise.
+
+ 규칙:
+ - 앞뒤 공백·슬래시 제거
+ - segment별 trim
+ - 빈 segment(// 또는 공백만) → ValueError
+ - segment 30자 초과 → ValueError
+ - 5단계 초과 → ValueError
+
+ GET /documents/library?path= 쿼리에도 동일하게 적용.
+ """
+ stripped = raw.strip().strip("/")
+ if not stripped:
+ raise ValueError("빈 경로")
+
+ segments = stripped.split("/")
+ normalized: list[str] = []
+ for s in segments:
+ s = s.strip()
+ if not s:
+ raise ValueError("빈 세그먼트 (// 또는 공백만 있는 구간)")
+ if len(s) > MAX_SEGMENT_LEN:
+ raise ValueError(f"세그먼트 '{s}'가 {MAX_SEGMENT_LEN}자 초과")
+ normalized.append(s)
+
+ if len(normalized) > MAX_DEPTH:
+ raise ValueError(f"최대 {MAX_DEPTH}단계까지 가능")
+
+ return "/".join(normalized)
+
+
+def extract_library_paths(user_tags: list[str] | None) -> list[str]:
+ """user_tags에서 @library/ 경로만 추출 (prefix 포함)."""
+ if not user_tags:
+ return []
+ return [t for t in user_tags if t.startswith(LIBRARY_PREFIX)]
+
+
+def validate_user_tags(tags: list) -> list[str]:
+ """user_tags 전체 검증. 입력 순서 보존, 중복 제거.
+
+ - 문자열이 아닌 원소 → TypeError
+ - 빈 문자열 / 공백만 있는 태그 → 제거
+ - 일반 태그 → strip() 후 통과
+ - @library/ 태그 → normalize_library_path() 적용
+ - 중복 → 첫 출현만 유지 (입력 순서 보존)
+ """
+ result: list[str] = []
+ for tag in tags:
+ if not isinstance(tag, str):
+ raise TypeError(f"태그는 문자열이어야 합니다: {tag!r}")
+ tag = tag.strip()
+ if not tag:
+ continue
+ if tag.startswith(LIBRARY_PREFIX):
+ path = tag[len(LIBRARY_PREFIX):]
+ normalized = normalize_library_path(path)
+ tag = f"{LIBRARY_PREFIX}{normalized}"
+ result.append(tag)
+
+ # 중복 제거 (입력 순서 보존)
+ seen: set[str] = set()
+ deduped: list[str] = []
+ for t in result:
+ if t not in seen:
+ seen.add(t)
+ deduped.append(t)
+ return deduped
diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte
index 15e4c29..0742713 100644
--- a/frontend/src/lib/components/Sidebar.svelte
+++ b/frontend/src/lib/components/Sidebar.svelte
@@ -2,7 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
- import { ChevronRight, ChevronDown, FolderOpen, Inbox, Clock, Mail, Scale, StickyNote } from 'lucide-svelte';
+ import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote } from 'lucide-svelte';
let tree = $state([]);
let loading = $state(true);
@@ -188,6 +188,13 @@
>
→ {normalizedPreview()}
+ {/if} +