From deb5c1b7049f99fd7402e35aadd41b8249c01a5d Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 14 Apr 2026 14:55:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(library):=20=EC=9E=90=EB=A3=8C=EC=8B=A4=20?= =?UTF-8?q?=E2=80=94=20=ED=83=9C=EA=B7=B8=20=EA=B8=B0=EB=B0=98=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=AC=B8=EC=84=9C=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목적성 문서(양식, 템플릿, 연간보고서)를 @library/ 태그로 분류하고 트리 구조로 탐색하는 자료실 페이지 추가. 백엔드: 경로 정규화 유틸, library-tree/library 엔드포인트, 다운로드 Content-Disposition 개선(원본/PDF 분리, 한글 filename*) 프론트: /library 페이지, LibraryPathEditor, 상단 nav/사이드바 링크 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/documents.py | 161 ++++++- app/core/library.py | 79 ++++ frontend/src/lib/components/Sidebar.svelte | 9 +- .../editors/LibraryPathEditor.svelte | 216 +++++++++ frontend/src/routes/+layout.svelte | 4 +- .../src/routes/documents/[id]/+page.svelte | 27 +- frontend/src/routes/library/+page.svelte | 414 ++++++++++++++++++ migrations/114_library_user_tags_gin.sql | 4 + 8 files changed, 900 insertions(+), 14 deletions(-) create mode 100644 app/core/library.py create mode 100644 frontend/src/lib/components/editors/LibraryPathEditor.svelte create mode 100644 frontend/src/routes/library/+page.svelte create mode 100644 migrations/114_library_user_tags_gin.sql 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 @@ > 이메일 + + 자료실 + diff --git a/frontend/src/lib/components/editors/LibraryPathEditor.svelte b/frontend/src/lib/components/editors/LibraryPathEditor.svelte new file mode 100644 index 0000000..ffcf8fe --- /dev/null +++ b/frontend/src/lib/components/editors/LibraryPathEditor.svelte @@ -0,0 +1,216 @@ + + +
+

+ + 자료실 경로 +

+ + + {#if libraryPaths.length > 0} +
+ {#each libraryPaths as path} +
+ {path} + +
+ {/each} +
+ {/if} + + + {#if editing} +
+
{ + e.preventDefault(); + addPath(newPath); + }} + class="flex gap-1" + > + (showSuggestions = true)} + onblur={() => setTimeout(() => (showSuggestions = false), 200)} + aria-label="자료실 경로" + placeholder="회사A/양식/안전" + class="flex-1 px-2 py-1 bg-bg border border-default rounded text-xs text-text outline-none focus:border-accent" + /> + +
+ + + {#if showSuggestions && suggestions.length > 0} +
+ {#each suggestions as suggestion} + + {/each} +
+ {/if} + + + {#if newPath.trim() && normalizedPreview() && normalizedPreview() !== newPath.trim()} +

→ {normalizedPreview()}

+ {/if} +
+ {:else} + + {/if} +
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 030f097..c9c2473 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -82,7 +82,9 @@ {/if} PKM / - 문서 + 문서 + · + 자료실
diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 43bcdda..34f8e85 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -20,6 +20,7 @@ import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte'; import FileInfoView from '$lib/components/editors/FileInfoView.svelte'; import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte'; + import LibraryPathEditor from '$lib/components/editors/LibraryPathEditor.svelte'; import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte'; marked.use({ mangle: false, headerIds: false }); @@ -80,14 +81,12 @@ .catch(() => addToast('error', '복사 실패')); } - function downloadFile() { - const url = `/api/documents/${docId}/file?token=${getAccessToken()}`; - const a = document.createElement('a'); - a.href = url; - a.download = doc?.title || `document-${docId}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + function downloadOriginal() { + window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`); + } + + function downloadPdf() { + window.open(`/api/documents/${docId}/preview?token=${getAccessToken()}&download=true`); } function handleDocDelete() { @@ -141,9 +140,14 @@ Synology 편집 {/if} - + {#if doc.preview_status === 'ready'} + + {/if} @@ -225,6 +229,9 @@