From e435332ea1f2333d0c955324c10065cb0cf7fa2b Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 14 Apr 2026 08:36:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(memos):=20UX=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=E2=80=94=20=ED=8E=B8=EC=A7=91=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EC=A0=9C=EA=B1=B0,=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=B0=95=EC=8A=A4,=20=EC=95=84=EC=B9=B4=EC=9D=B4=EB=B8=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A: 편집 버그 수정 (content만 PATCH, Ctrl+Enter/Esc), 제목 UI 제거 (자동생성 80자, 내부용), 카드 경량화. Phase B: GFM task list 지원, taskIndex 기반 인터랙티브 토글, DOMPurify checkbox 최소 허용, optimistic update + 롤백. Phase C: archived 컬럼 (메모 UX 전용, 문서 미노출), 멱등 세팅 API (토글 아님), 활성/아카이브 뷰 분리 쿼리, 핀은 활성 메모용 (archived 시 무시). Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/memos.py | 120 +++++--- app/models/document.py | 3 + frontend/src/routes/memos/+page.svelte | 404 +++++++++++++++---------- migrations/113_memo_archived.sql | 2 + 4 files changed, 320 insertions(+), 209 deletions(-) create mode 100644 migrations/113_memo_archived.sql diff --git a/app/api/memos.py b/app/api/memos.py index 3e889bd..cbb673f 100644 --- a/app/api/memos.py +++ b/app/api/memos.py @@ -39,10 +39,16 @@ def _content_hash(content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() +def _auto_title(content: str) -> str: + """첫 줄에서 제목 자동 생성 (80자 절단, 마크다운 헤딩 제거)""" + first_line = content.split("\n", 1)[0].strip() + title = re.sub(r"^#+\s*", "", first_line)[:80] or "메모" + return title + + async def _enqueue_ai_stages(session: AsyncSession, document_id: int): """classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지).""" stages = ["classify", "embed", "chunk"] - # 기존 pending 건 삭제 (processing은 건드리지 않음 — worker 충돌 방지) await session.execute( delete(ProcessingQueue).where( ProcessingQueue.document_id == document_id, @@ -63,13 +69,15 @@ async def _enqueue_ai_stages(session: AsyncSession, document_id: int): class MemoCreate(BaseModel): content: str - title: str | None = None ask_includable: bool = True class MemoUpdate(BaseModel): - content: str | None = None - title: str | None = None + content: str + + +class ArchiveSet(BaseModel): + archived: bool class MemoResponse(BaseModel): @@ -83,6 +91,7 @@ class MemoResponse(BaseModel): ai_sub_group: str | None ai_summary: str | None pinned: bool + archived: bool ask_includable: bool created_at: datetime updated_at: datetime @@ -110,6 +119,7 @@ def _to_memo_response(doc: Document) -> MemoResponse: ai_sub_group=doc.ai_sub_group, ai_summary=doc.ai_summary, pinned=doc.pinned, + archived=doc.archived, ask_includable=doc.ask_includable, created_at=doc.created_at, updated_at=doc.updated_at, @@ -130,31 +140,23 @@ async def create_memo( if not content: raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다") - # 제목: 명시 지정 또는 첫 줄에서 추출 - title = body.title - if not title: - first_line = content.split("\n", 1)[0].strip() - # 마크다운 헤딩 제거 - title = re.sub(r"^#+\s*", "", first_line)[:100] or "메모" - - user_tags = _parse_hashtags(content) - doc = Document( file_path=None, file_hash=_content_hash(content), file_format="md", file_size=len(content.encode("utf-8")), file_type="note", - title=title, + title=_auto_title(content), extracted_text=content, review_status="approved", source_channel="memo", - user_tags=user_tags, + user_tags=_parse_hashtags(content), pinned=False, + archived=False, ask_includable=body.ask_includable, ) session.add(doc) - await session.flush() # ID 확보 + await session.flush() await _enqueue_ai_stages(session, doc.id) await session.commit() @@ -170,15 +172,16 @@ async def list_memos( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), tag: str | None = Query(None, description="user_tags 또는 ai_tags 필터"), + archived: bool = Query(False, description="true면 아카이브 목록"), ): - """메모 목록 — 핀 우선 + 최신순""" + """메모 목록 — 활성: 핀 우선 + 최신순 / 아카이브: 최신순 (핀 무시)""" base = select(Document).where( Document.file_type == "note", Document.deleted_at == None, # noqa: E711 + Document.archived == archived, ) if tag: - # user_tags 또는 ai_tags에 포함된 태그 필터 base = base.where( Document.user_tags.op("@>")(f'["{tag}"]') | Document.ai_tags.op("@>")(f'["{tag}"]') @@ -187,11 +190,13 @@ async def list_memos( count_query = select(func.count()).select_from(base.subquery()) total = (await session.execute(count_query)).scalar() or 0 - query = base.order_by( - Document.pinned.desc(), - Document.created_at.desc(), - ).offset((page - 1) * page_size).limit(page_size) + # 활성: pinned DESC + created_at DESC / 아카이브: created_at DESC (핀 무시) + if archived: + query = base.order_by(Document.created_at.desc()) + else: + query = base.order_by(Document.pinned.desc(), Document.created_at.desc()) + query = query.offset((page - 1) * page_size).limit(page_size) result = await session.execute(query) items = result.scalars().all() @@ -228,37 +233,34 @@ async def update_memo( if not doc or doc.file_type != "note" or doc.deleted_at is not None: raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") - if body.title is not None: - doc.title = body.title + content = body.content.strip() + if not content: + raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다") - if body.content is not None: - content = body.content.strip() - if not content: - raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다") + doc.extracted_text = content + doc.file_hash = _content_hash(content) + doc.file_size = len(content.encode("utf-8")) + doc.title = _auto_title(content) + doc.user_tags = _parse_hashtags(content) - doc.extracted_text = content - doc.file_hash = _content_hash(content) - doc.file_size = len(content.encode("utf-8")) - doc.user_tags = _parse_hashtags(content) + # stale AI 데이터 즉시 초기화 + doc.ai_summary = None + doc.ai_domain = None + doc.ai_sub_group = None + doc.ai_tags = None + doc.ai_confidence = None + doc.ai_processed_at = None + doc.embedding = None + doc.embedded_at = None - # stale AI 데이터 즉시 초기화 - doc.ai_summary = None - doc.ai_domain = None - doc.ai_sub_group = None - doc.ai_tags = None - doc.ai_confidence = None - doc.ai_processed_at = None - doc.embedding = None - doc.embedded_at = None + # 기존 chunks 삭제 + from models.chunk import DocumentChunk + await session.execute( + delete(DocumentChunk).where(DocumentChunk.document_id == memo_id) + ) - # 기존 chunks 삭제 - from models.chunk import DocumentChunk - await session.execute( - delete(DocumentChunk).where(DocumentChunk.document_id == memo_id) - ) - - # 재처리 큐 등록 (pending만 정리, processing은 건드리지 않음) - await _enqueue_ai_stages(session, memo_id) + # 재처리 큐 등록 + await _enqueue_ai_stages(session, memo_id) doc.updated_at = datetime.now(timezone.utc) await session.commit() @@ -301,6 +303,26 @@ async def toggle_pin( return _to_memo_response(doc) +@router.patch("/{memo_id}/archive", response_model=MemoResponse) +async def set_archive( + memo_id: int, + body: ArchiveSet, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """메모 아카이브 설정 (멱등, 토글 아님)""" + doc = await session.get(Document, memo_id) + if not doc or doc.file_type != "note" or doc.deleted_at is not None: + raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") + + doc.archived = body.archived + doc.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(doc) + + return _to_memo_response(doc) + + @router.patch("/{memo_id}/ask-includable", response_model=MemoResponse) async def toggle_ask_includable( memo_id: int, diff --git a/app/models/document.py b/app/models/document.py index 4c4f28d..a51476c 100644 --- a/app/models/document.py +++ b/app/models/document.py @@ -61,6 +61,9 @@ class Document(Base): # /ask 합성 포함 여부 (false면 검색은 되지만 evidence에서 제외) ask_includable: Mapped[bool] = mapped_column(Boolean, default=True) + # 아카이브 (현재 메모 UX 전용, 문서 쪽에는 노출하지 않음) + archived: Mapped[bool] = mapped_column(Boolean, default=False) + # ODF 변환 derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/) original_format: Mapped[str | None] = mapped_column(String(20)) diff --git a/frontend/src/routes/memos/+page.svelte b/frontend/src/routes/memos/+page.svelte index 8810b9a..54a6c1c 100644 --- a/frontend/src/routes/memos/+page.svelte +++ b/frontend/src/routes/memos/+page.svelte @@ -4,32 +4,69 @@ import { addToast } from '$lib/stores/toast'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; - import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check } from 'lucide-svelte'; + import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore } from 'lucide-svelte'; import Button from '$lib/components/ui/Button.svelte'; import Card from '$lib/components/ui/Card.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte'; - marked.use({ mangle: false, headerIds: false }); + // GFM 활성화 (task list 지원) + marked.use({ gfm: true }); + + // task list 줄 패턴 + const TASK_RE = /^(\s*- \[)([ x])(\].*)$/; + + // 렌더링 — #태그 링크 + task checkbox에 data-task-index 부여 function renderMd(text) { // #태그를 클릭 가능한 링크로 변환 const withTags = text.replace( /(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})/g, '#$1' ); - return DOMPurify.sanitize(marked(withTags), { + let html = marked(withTags); + + // task checkbox에 data-task-index 부여 + let taskIdx = 0; + html = html.replace( + //gi, + (match) => { + const idx = taskIdx++; + const checked = match.includes('checked'); + return ``; + } + ); + + return DOMPurify.sanitize(html, { USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'script'], FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false, + ADD_TAGS: ['input'], + ADD_ATTR: ['type', 'checked', 'data-task-index'], }); } + // task toggle — n번째 task item 줄만 토글 + function toggleTask(content, taskIndex) { + const lines = content.split('\n'); + let ti = 0; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(TASK_RE); + if (m) { + if (ti === taskIndex) { + lines[i] = m[1] + (m[2] === ' ' ? 'x' : ' ') + m[3]; + break; + } + ti++; + } + } + return lines.join('\n'); + } + let memos = $state([]); let total = $state(0); let page = $state(1); let loading = $state(true); - let creating = $state(false); // 빠른 입력 let newContent = $state(''); @@ -38,11 +75,11 @@ // 인라인 편집 let editingId = $state(null); let editContent = $state(''); - let editTitle = $state(''); let saving = $state(false); - // 태그 필터 + // 필터 let activeTag = $state(null); + let showArchived = $state(false); onMount(() => { const params = new URLSearchParams(window.location.search); @@ -54,7 +91,7 @@ loading = true; try { const tagParam = activeTag ? `&tag=${encodeURIComponent(activeTag)}` : ''; - const res = await api(`/memos?page=${page}&page_size=20${tagParam}`); + const res = await api(`/memos?page=${page}&page_size=20&archived=${showArchived}${tagParam}`); memos = res.items; total = res.total; } catch (err) { @@ -87,13 +124,11 @@ function startEdit(memo) { editingId = memo.id; editContent = memo.content || ''; - editTitle = memo.title || ''; } function cancelEdit() { editingId = null; editContent = ''; - editTitle = ''; } async function saveEdit(memoId) { @@ -101,13 +136,14 @@ try { const updated = await api(`/memos/${memoId}`, { method: 'PATCH', - body: JSON.stringify({ content: editContent, title: editTitle }), + body: JSON.stringify({ content: editContent }), }); memos = memos.map((m) => (m.id === memoId ? updated : m)); editingId = null; addToast('success', '메모 수정됨'); } catch (err) { addToast('error', '메모 수정 실패'); + // 실패 시 편집 상태 유지 (내용 손실 방지) } finally { saving = false; } @@ -129,16 +165,32 @@ try { const updated = await api(`/memos/${memoId}/pin`, { method: 'PATCH' }); memos = memos.map((m) => (m.id === memoId ? updated : m)); - // 핀 변경 시 정렬 재적용 - memos = [...memos].sort((a, b) => { - if (a.pinned !== b.pinned) return b.pinned ? 1 : -1; - return new Date(b.created_at) - new Date(a.created_at); - }); + if (!showArchived) { + memos = [...memos].sort((a, b) => { + if (a.pinned !== b.pinned) return b.pinned ? 1 : -1; + return new Date(b.created_at) - new Date(a.created_at); + }); + } } catch (err) { addToast('error', '핀 변경 실패'); } } + async function setArchive(memoId, archived) { + try { + await api(`/memos/${memoId}/archive`, { + method: 'PATCH', + body: JSON.stringify({ archived }), + }); + // 아카이브/복원 시 현재 뷰에서 제거 + memos = memos.filter((m) => m.id !== memoId); + total -= 1; + addToast('success', archived ? '아카이브됨' : '복원됨'); + } catch (err) { + addToast('error', archived ? '아카이브 실패' : '복원 실패'); + } + } + async function toggleAskIncludable(memoId) { try { const updated = await api(`/memos/${memoId}/ask-includable`, { method: 'PATCH' }); @@ -148,6 +200,36 @@ } } + // 체크박스 클릭 핸들러 (이벤트 위임) + async function handleCheckboxClick(e, memo) { + const target = e.target; + if (target.tagName !== 'INPUT' || target.type !== 'checkbox') return; + + e.preventDefault(); + const taskIndex = parseInt(target.dataset.taskIndex, 10); + if (isNaN(taskIndex)) return; + + const oldContent = memo.content; + const newCont = toggleTask(oldContent, taskIndex); + + // optimistic update + memo.content = newCont; + memos = [...memos]; + + try { + const updated = await api(`/memos/${memo.id}`, { + method: 'PATCH', + body: JSON.stringify({ content: newCont }), + }); + memos = memos.map((m) => (m.id === memo.id ? updated : m)); + } catch (err) { + // 롤백 + memo.content = oldContent; + memos = [...memos]; + addToast('error', '체크박스 변경 실패'); + } + } + function clearTag() { activeTag = null; history.replaceState(null, '', '/memos'); @@ -161,6 +243,12 @@ loadMemos(); } + function toggleArchiveView() { + showArchived = !showArchived; + page = 1; + loadMemos(); + } + function handleKeydown(e) { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); @@ -168,6 +256,16 @@ } } + function handleEditKeydown(e, memoId) { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + saveEdit(memoId); + } + if (e.key === 'Escape') { + cancelEdit(); + } + } + function formatTime(dateStr) { const d = new Date(dateStr); const now = new Date(); @@ -186,16 +284,26 @@
-
+

메모

- {#if total > 0} - {total}개 - {/if} +
+ + {#if total > 0} + {total}개 + {/if} +
{#if activeTag} -
+
필터: -
- + + {#if !showArchived} + + +
+ +
+
+ {/if} {#if loading} {#each Array(3) as _} -
- +
+
{/each} {:else if memos.length === 0} - {#if activeTag} + {#if showArchived} +

아카이브된 메모가 없습니다

+ {:else if activeTag}

#{activeTag} 태그의 메모가 없습니다

{:else}

아직 메모가 없습니다

@@ -246,71 +357,68 @@ {/if}
{:else} -
+
{#each memos as memo (memo.id)} - + - {#if memo.pinned} + {#if memo.pinned && !showArchived}
- +
{/if} -
+
{#if editingId === memo.id} -
- - +
+ Ctrl+Enter 저장 / Esc 취소 +
+ + +
{:else} - {#if memo.title} -

{memo.title}

- {/if} -
+ + +
handleCheckboxClick(e, memo)} + > {@html renderMd(memo.content || '')}
{/if} - - {#if (memo.user_tags?.length || memo.ai_tags?.length) && editingId !== memo.id} -
- {#each memo.user_tags || [] as tag} - - {/each} - {#each memo.ai_tags || [] as tag} - - {/each} -
- {/if} - - + {#if editingId !== memo.id} -
+ + {#if memo.user_tags?.length || memo.ai_tags?.length} +
+ {#each memo.user_tags || [] as tag} + + {/each} + {#each memo.ai_tags || [] as tag} + + {/each} +
+ {/if} + + +
- {formatTime(memo.created_at)} - {#if memo.ai_domain} - | {memo.ai_domain} - {/if} + {formatTime(memo.created_at)} {#if !memo.ask_includable} AI 제외 @@ -319,42 +427,45 @@
- + {#if !showArchived} + + {/if} - + {#if !showArchived} + + + {:else} + + {/if} + >
{/if} @@ -366,56 +477,29 @@ {#if total > 20}
- + {page} / {Math.ceil(total / 20)} - +
{/if} {/if}
diff --git a/migrations/113_memo_archived.sql b/migrations/113_memo_archived.sql new file mode 100644 index 0000000..959a1a2 --- /dev/null +++ b/migrations/113_memo_archived.sql @@ -0,0 +1,2 @@ +-- 113: 메모 아카이브. 현재 메모(file_type='note') UX 전용. 문서 쪽에는 노출하지 않음. +ALTER TABLE documents ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT false