feat(memos): 내장 메모 기능 — 파일 없는 문서(file_type='note')

Document Server에 Memos 앱 대체 기능 내장. 메모를 documents 테이블의
file_type='note' 레코드로 관리하여 기존 AI 파이프라인(classify/embed/
chunk/search/ask) 재활용.

Backend:
- migration 105: source_channel 'memo', file_path NULL 허용,
  user_tags/pinned/ask_includable 컬럼, 메모 인덱스
- api/memos.py: CRUD 7개 엔드포인트 + #태그 파싱 + stale AI 초기화
  + 큐 pending 중복 방지
- queue_consumer: note extract/preview skip
- documents API: file_path NULL 가드, 목록에서 메모 제외
- search /ask: ask_includable=false 문서 evidence 제외

Frontend:
- /memos 타임라인 페이지 (빠른 입력 + 피드 + 인라인 편집 + 태그 필터)
- QuickMemoButton FAB (Ctrl+M, 모든 페이지)
- Sidebar 메모 링크

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-13 16:00:00 +09:00
parent 33ce4292ca
commit b46a75758b
11 changed files with 981 additions and 8 deletions
+18 -3
View File
@@ -27,7 +27,7 @@ router = APIRouter()
class DocumentResponse(BaseModel):
id: int
file_path: str
file_path: str | None
file_format: str
file_size: int | None
file_type: str
@@ -40,6 +40,9 @@ class DocumentResponse(BaseModel):
importance: str | None
ai_confidence: float | None
user_note: str | None
user_tags: list | None
pinned: bool | None
ask_includable: bool | None
derived_path: str | None
original_format: str | None
conversion_status: str | None
@@ -149,8 +152,12 @@ async def list_documents(
format: str | None = None,
review_status: str | None = Query(None, description="pending | approved | rejected"),
):
"""문서 목록 조회 (페이지네이션 + 필터, 뉴스 제외)"""
query = select(Document).where(Document.deleted_at == None, Document.source_channel != "news")
"""문서 목록 조회 (페이지네이션 + 필터, 뉴스/메모 제외)"""
query = select(Document).where(
Document.deleted_at == None, # noqa: E711
Document.source_channel != "news",
Document.file_type != "note",
)
if domain:
# prefix 매칭: Industrial_Safety 클릭 시 하위 전부 포함
@@ -216,6 +223,10 @@ async def get_document_file(
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
# note(메모)는 물리 파일이 없음
if not doc.file_path:
raise HTTPException(status_code=404, detail="파일이 없는 문서입니다 (메모)")
file_path = Path(settings.nas_mount_path) / doc.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
@@ -340,6 +351,10 @@ async def save_document_content(
if doc.file_format not in ("md", "txt"):
raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)")
# note(메모)는 /api/memos/{id} PATCH로 수정
if not doc.file_path:
raise HTTPException(status_code=400, detail="파일이 없는 문서입니다. 메모는 /api/memos 사용")
content = body.get("content", "") if body else ""
file_path = Path(settings.nas_mount_path) / doc.file_path
file_path.write_text(content, encoding="utf-8")
+320
View File
@@ -0,0 +1,320 @@
"""메모 CRUD API — 파일 없는 문서(file_type='note')"""
import hashlib
import re
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from core.database import get_session
from models.document import Document
from models.queue import ProcessingQueue
from models.user import User
router = APIRouter()
# #태그 파싱 패턴: 한글/영문/숫자/밑줄, 2자 이상
TAG_PATTERN = re.compile(r"(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})")
def _parse_hashtags(content: str) -> list[str]:
"""본문에서 #태그 추출, 중복 제거, 순서 유지"""
seen: set[str] = set()
tags: list[str] = []
for m in TAG_PATTERN.finditer(content):
tag = m.group(1)
if tag not in seen:
seen.add(tag)
tags.append(tag)
return tags
def _content_hash(content: str) -> str:
"""메모 본문의 SHA-256 해시 (note의 file_hash = content hash)"""
return hashlib.sha256(content.encode("utf-8")).hexdigest()
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,
ProcessingQueue.stage.in_(stages),
ProcessingQueue.status == "pending",
)
)
for stage in stages:
session.add(ProcessingQueue(
document_id=document_id,
stage=stage,
status="pending",
))
# ─── 스키마 ───
class MemoCreate(BaseModel):
content: str
title: str | None = None
ask_includable: bool = True
class MemoUpdate(BaseModel):
content: str | None = None
title: str | None = None
class MemoResponse(BaseModel):
id: int
title: str | None
content: str | None # extracted_text
file_format: str
user_tags: list | None
ai_tags: list | None
ai_domain: str | None
ai_sub_group: str | None
ai_summary: str | None
pinned: bool
ask_includable: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class MemoListResponse(BaseModel):
items: list[MemoResponse]
total: int
page: int
page_size: int
def _to_memo_response(doc: Document) -> MemoResponse:
return MemoResponse(
id=doc.id,
title=doc.title,
content=doc.extracted_text,
file_format=doc.file_format,
user_tags=doc.user_tags,
ai_tags=doc.ai_tags,
ai_domain=doc.ai_domain,
ai_sub_group=doc.ai_sub_group,
ai_summary=doc.ai_summary,
pinned=doc.pinned,
ask_includable=doc.ask_includable,
created_at=doc.created_at,
updated_at=doc.updated_at,
)
# ─── 엔드포인트 ───
@router.post("/", response_model=MemoResponse, status_code=201)
async def create_memo(
body: MemoCreate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 생성 — file_type='note', 파일 없는 문서"""
content = body.content.strip()
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,
extracted_text=content,
review_status="approved",
source_channel="memo",
user_tags=user_tags,
pinned=False,
ask_includable=body.ask_includable,
)
session.add(doc)
await session.flush() # ID 확보
await _enqueue_ai_stages(session, doc.id)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
@router.get("/", response_model=MemoListResponse)
async def list_memos(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
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 필터"),
):
"""메모 목록 — 핀 우선 + 최신순"""
base = select(Document).where(
Document.file_type == "note",
Document.deleted_at == None, # noqa: E711
)
if tag:
# user_tags 또는 ai_tags에 포함된 태그 필터
base = base.where(
Document.user_tags.op("@>")(f'["{tag}"]')
| Document.ai_tags.op("@>")(f'["{tag}"]')
)
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)
result = await session.execute(query)
items = result.scalars().all()
return MemoListResponse(
items=[_to_memo_response(doc) for doc in items],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{memo_id}", response_model=MemoResponse)
async def get_memo(
memo_id: int,
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="메모를 찾을 수 없습니다")
return _to_memo_response(doc)
@router.patch("/{memo_id}", response_model=MemoResponse)
async def update_memo(
memo_id: int,
body: MemoUpdate,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 수정 — content 변경 시 AI 데이터 초기화 + 재처리 큐 등록"""
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="메모를 찾을 수 없습니다")
if body.title is not None:
doc.title = body.title
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.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
# 기존 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)
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
@router.delete("/{memo_id}", status_code=204)
async def delete_memo(
memo_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 soft delete"""
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.deleted_at = datetime.now(timezone.utc)
await session.commit()
@router.patch("/{memo_id}/pin", response_model=MemoResponse)
async def toggle_pin(
memo_id: int,
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.pinned = not doc.pinned
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,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""/ask 합성 포함 여부 토글"""
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.ask_includable = not doc.ask_includable
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
+20 -1
View File
@@ -389,9 +389,28 @@ async def ask(
fusion=DEFAULT_FUSION, rerank=True, analyze=True,
)
# 1.5. ask_includable=false 문서를 evidence 입력에서 제외
# 검색 결과 자체는 유지 (사용자에게 보여줌), evidence만 필터
if pr.results:
from sqlalchemy import select as sa_select
from models.document import Document as DocModel
ask_doc_ids = set()
excluded_ids = {r.id for r in pr.results}
rows = await session.execute(
sa_select(DocModel.id, DocModel.ask_includable).where(
DocModel.id.in_(excluded_ids)
)
)
for doc_id, includable in rows:
if includable is False:
ask_doc_ids.add(doc_id)
evidence_results = [r for r in pr.results if r.id not in ask_doc_ids]
else:
evidence_results = pr.results
# 2. Evidence + Classifier 병렬
t_ev = time.perf_counter()
evidence_task = asyncio.create_task(extract_evidence(q, pr.results))
evidence_task = asyncio.create_task(extract_evidence(q, evidence_results))
# classifier input: top 3 chunks meta + rerank scores
top_chunks = [
+2
View File
@@ -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.memos import router as memos_router
from api.news import router as news_router
from api.search import router as search_router
from api.setup import router as setup_router
@@ -89,6 +90,7 @@ app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
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(news_router, prefix="/api/news", tags=["news"])
app.include_router(digest_router, prefix="/api/digest", tags=["digest"])
+13 -2
View File
@@ -7,6 +7,8 @@ from sqlalchemy import BigInteger, Boolean, DateTime, Enum, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
# Note: file_type='note' (메모) 문서는 file_path=NULL, file_hash=content SHA-256
from core.database import Base
@@ -16,7 +18,7 @@ class Document(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
# 1계층: 원본 파일
file_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
file_hash: Mapped[str] = mapped_column(String(64), nullable=False)
file_format: Mapped[str] = mapped_column(String(20), nullable=False)
file_size: Mapped[int | None] = mapped_column(BigInteger)
@@ -50,6 +52,15 @@ class Document(Base):
# 사용자 메모
user_note: Mapped[str | None] = mapped_column(Text)
# 사용자 태그 (ai_tags와 분리, #태그 파싱 결과 또는 수동 입력)
user_tags: Mapped[list | None] = mapped_column(JSONB, default=[])
# 핀 고정
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
# /ask 합성 포함 여부 (false면 검색은 되지만 evidence에서 제외)
ask_includable: Mapped[bool] = mapped_column(Boolean, default=True)
# ODF 변환
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
original_format: Mapped[str | None] = mapped_column(String(20))
@@ -73,7 +84,7 @@ class Document(Base):
# 메타데이터
source_channel: Mapped[str | None] = mapped_column(
Enum("law_monitor", "devonagent", "email", "web_clip",
"tksafety", "inbox_route", "manual", "drive_sync", "news",
"tksafety", "inbox_route", "manual", "drive_sync", "news", "memo",
name="source_channel")
)
data_origin: Mapped[str | None] = mapped_column(
+16
View File
@@ -109,6 +109,22 @@ async def consume_queue():
# 워커 실행 (독립 세션)
try:
# note(메모)는 이미 extracted_text가 있으므로 extract/preview skip
if stage in ("extract", "preview"):
from models.document import Document
async with async_session() as check_session:
doc = await check_session.get(Document, document_id)
if doc and doc.file_type == "note":
async with async_session() as skip_session:
item = await skip_session.get(ProcessingQueue, queue_id)
if item:
item.status = "completed"
item.completed_at = datetime.now(timezone.utc)
await skip_session.commit()
await enqueue_next_stage(document_id, stage)
logger.info(f"[{stage}] document_id={document_id} skip (note)")
continue
async with async_session() as worker_session:
await worker_fn(document_id, worker_session)
await worker_session.commit()
@@ -0,0 +1,127 @@
<script>
import { StickyNote, X, Send } from 'lucide-svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/Button.svelte';
let open = $state(false);
let content = $state('');
let submitting = $state(false);
function toggle() {
open = !open;
if (open) {
// 다음 틱에 textarea에 포커스
requestAnimationFrame(() => {
document.querySelector('[data-quick-memo-input]')?.focus();
});
}
}
function close() {
open = false;
}
async function submit() {
const text = content.trim();
if (!text) return;
submitting = true;
try {
await api('/memos', {
method: 'POST',
body: JSON.stringify({ content: text }),
});
content = '';
open = false;
addToast('success', '메모 생성됨');
} catch (err) {
addToast('error', '메모 생성 실패');
} finally {
submitting = false;
}
}
function handleKeydown(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submit();
}
if (e.key === 'Escape') {
close();
}
}
// Ctrl+M 글로벌 단축키
function handleGlobalKeydown(e) {
if (e.key === 'm' && (e.ctrlKey || e.metaKey) && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
e.preventDefault();
toggle();
}
}
</script>
<svelte:window on:keydown={handleGlobalKeydown} />
<!-- FAB 버튼 -->
{#if !open}
<button
onclick={toggle}
class="fixed bottom-6 right-6 z-40 w-12 h-12 rounded-full bg-accent text-white shadow-lg
flex items-center justify-center hover:bg-accent-hover transition-colors
focus-visible:ring-2 focus-visible:ring-accent-ring focus-visible:outline-none"
title="빠른 메모 (Ctrl+M)"
>
<StickyNote size={20} />
</button>
{/if}
<!-- 빠른 메모 패널 -->
{#if open}
<!-- 배경 오버레이 -->
<button
onclick={close}
class="fixed inset-0 z-40 bg-black/30"
aria-label="닫기"
></button>
<!-- 패널 -->
<div class="fixed bottom-6 right-6 z-50 w-80 bg-surface border border-default rounded-xl shadow-2xl">
<div class="flex items-center justify-between px-4 py-3 border-b border-default">
<span class="text-sm font-semibold text-text">빠른 메모</span>
<button onclick={close} class="p-1 rounded text-dim hover:text-text hover:bg-surface-hover transition-colors">
<X size={16} />
</button>
</div>
<div class="p-4">
<textarea
data-quick-memo-input
bind:value={content}
onkeydown={handleKeydown}
placeholder="메모 입력... (Ctrl+Enter)"
class="w-full h-28 bg-bg border border-default rounded-md px-3 py-2 text-sm text-text
resize-none outline-none focus:border-accent placeholder:text-dim"
></textarea>
<div class="flex justify-between items-center mt-3">
<span class="text-[11px] text-dim">#태그 지원</span>
<div class="flex gap-2">
<Button variant="ghost" size="sm" onclick={() => { close(); goto('/memos'); }}>
목록
</Button>
<Button
variant="primary"
size="sm"
icon={Send}
loading={submitting}
disabled={!content.trim()}
onclick={submit}
>
저장
</Button>
</div>
</div>
</div>
</div>
{/if}
+12 -2
View File
@@ -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 } from 'lucide-svelte';
import { ChevronRight, ChevronDown, FolderOpen, Inbox, Clock, Mail, Scale, StickyNote } from 'lucide-svelte';
let tree = $state([]);
let loading = $state(true);
@@ -190,8 +190,18 @@
</button>
</div>
<!-- Inbox -->
<!-- 메모 & Inbox -->
<div class="px-2 py-2 border-t border-default">
<a
href="/memos"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
{$page.url.pathname === '/memos' ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
>
<span class="flex items-center gap-2">
<StickyNote size={16} />
메모
</span>
</a>
<a
href="/inbox"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm text-text hover:bg-surface transition-colors"
+4
View File
@@ -9,6 +9,7 @@
import { ui } from '$lib/stores/uiState.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import SystemStatusDot from '$lib/components/SystemStatusDot.svelte';
import QuickMemoButton from '$lib/components/QuickMemoButton.svelte';
import Button from '$lib/components/ui/Button.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import Drawer from '$lib/components/ui/Drawer.svelte';
@@ -100,6 +101,9 @@
<slot />
</main>
</div>
<!-- 빠른 메모 FAB (모든 페이지) -->
<QuickMemoButton />
</div>
{:else}
<slot />
+421
View File
@@ -0,0 +1,421 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
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 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 });
function renderMd(text) {
// #태그를 클릭 가능한 링크로 변환
const withTags = text.replace(
/(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})/g,
'<a href="/memos?tag=$1" class="text-accent hover:underline">#$1</a>'
);
return DOMPurify.sanitize(marked(withTags), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onclick'],
ALLOW_UNKNOWN_PROTOCOLS: false,
});
}
let memos = $state([]);
let total = $state(0);
let page = $state(1);
let loading = $state(true);
let creating = $state(false);
// 빠른 입력
let newContent = $state('');
let submitting = $state(false);
// 인라인 편집
let editingId = $state(null);
let editContent = $state('');
let editTitle = $state('');
let saving = $state(false);
// 태그 필터
let activeTag = $state(null);
onMount(() => {
const params = new URLSearchParams(window.location.search);
activeTag = params.get('tag');
loadMemos();
});
async function loadMemos() {
loading = true;
try {
const tagParam = activeTag ? `&tag=${encodeURIComponent(activeTag)}` : '';
const res = await api(`/memos?page=${page}&page_size=20${tagParam}`);
memos = res.items;
total = res.total;
} catch (err) {
addToast('error', '메모 로딩 실패');
} finally {
loading = false;
}
}
async function createMemo() {
const content = newContent.trim();
if (!content) return;
submitting = true;
try {
const memo = await api('/memos', {
method: 'POST',
body: JSON.stringify({ content }),
});
memos = [memo, ...memos];
total += 1;
newContent = '';
addToast('success', '메모 생성됨');
} catch (err) {
addToast('error', '메모 생성 실패');
} finally {
submitting = false;
}
}
function startEdit(memo) {
editingId = memo.id;
editContent = memo.content || '';
editTitle = memo.title || '';
}
function cancelEdit() {
editingId = null;
editContent = '';
editTitle = '';
}
async function saveEdit(memoId) {
saving = true;
try {
const updated = await api(`/memos/${memoId}`, {
method: 'PATCH',
body: JSON.stringify({ content: editContent, title: editTitle }),
});
memos = memos.map((m) => (m.id === memoId ? updated : m));
editingId = null;
addToast('success', '메모 수정됨');
} catch (err) {
addToast('error', '메모 수정 실패');
} finally {
saving = false;
}
}
async function deleteMemo(memoId) {
if (!confirm('이 메모를 삭제하시겠습니까?')) return;
try {
await api(`/memos/${memoId}`, { method: 'DELETE' });
memos = memos.filter((m) => m.id !== memoId);
total -= 1;
addToast('success', '메모 삭제됨');
} catch (err) {
addToast('error', '메모 삭제 실패');
}
}
async function togglePin(memoId) {
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);
});
} catch (err) {
addToast('error', '핀 변경 실패');
}
}
async function toggleAskIncludable(memoId) {
try {
const updated = await api(`/memos/${memoId}/ask-includable`, { method: 'PATCH' });
memos = memos.map((m) => (m.id === memoId ? updated : m));
} catch (err) {
addToast('error', 'AI 포함 설정 변경 실패');
}
}
function clearTag() {
activeTag = null;
history.replaceState(null, '', '/memos');
loadMemos();
}
function filterByTag(tag) {
activeTag = tag;
page = 1;
history.replaceState(null, '', `/memos?tag=${encodeURIComponent(tag)}`);
loadMemos();
}
function handleKeydown(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
createMemo();
}
}
function formatTime(dateStr) {
const d = new Date(dateStr);
const now = new Date();
const diff = now - d;
if (diff < 60000) return '방금';
if (diff < 3600000) return `${Math.floor(diff / 60000)}분 전`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}시간 전`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)}일 전`;
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
</script>
<svelte:head>
<title>메모 — Document Server</title>
</svelte:head>
<div class="max-w-3xl mx-auto px-4 py-6">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-text">메모</h1>
{#if total > 0}
<span class="text-sm text-dim">{total}</span>
{/if}
</div>
<!-- 태그 필터 표시 -->
{#if activeTag}
<div class="flex items-center gap-2 mb-4">
<span class="text-sm text-dim">필터:</span>
<button
onclick={clearTag}
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
>
#{activeTag}
<X size={12} />
</button>
</div>
{/if}
<!-- 빠른 입력 -->
<Card class="mb-6">
<textarea
bind:value={newContent}
onkeydown={handleKeydown}
placeholder="메모 입력... (Ctrl+Enter로 저장, #태그 지원)"
class="w-full h-24 bg-transparent text-text text-sm resize-none outline-none placeholder:text-dim"
></textarea>
<div class="flex justify-between items-center mt-2">
<span class="text-xs text-dim">마크다운 지원</span>
<Button
variant="primary"
size="sm"
loading={submitting}
disabled={!newContent.trim()}
onclick={createMemo}
>
저장
</Button>
</div>
</Card>
<!-- 메모 피드 -->
{#if loading}
{#each Array(3) as _}
<div class="mb-4">
<Skeleton class="h-32 w-full rounded-card" />
</div>
{/each}
{:else if memos.length === 0}
<EmptyState>
{#if activeTag}
<p>#{activeTag} 태그의 메모가 없습니다</p>
{:else}
<p>아직 메모가 없습니다</p>
<p class="text-dim text-sm mt-1">위 입력창에서 첫 메모를 작성해보세요</p>
{/if}
</EmptyState>
{:else}
<div class="space-y-3">
{#each memos as memo (memo.id)}
<Card padded={false} class="group relative">
<!-- 핀 뱃지 -->
{#if memo.pinned}
<div class="absolute top-2 right-2">
<span class="text-xs text-accent"><Pin size={14} /></span>
</div>
{/if}
<div class="p-4">
{#if editingId === memo.id}
<!-- 인라인 편집 모드 -->
<textarea
bind:value={editContent}
class="w-full h-32 bg-bg border border-default rounded-md px-3 py-2 text-sm text-text resize-none outline-none focus:border-accent"
></textarea>
<div class="flex gap-2 mt-2">
<Button variant="primary" size="sm" icon={Check} loading={saving} onclick={() => saveEdit(memo.id)}>
저장
</Button>
<Button variant="ghost" size="sm" icon={X} onclick={cancelEdit}>
취소
</Button>
</div>
{:else}
<!-- 읽기 모드 -->
{#if memo.title}
<h3 class="text-sm font-semibold text-text mb-1">{memo.title}</h3>
{/if}
<div class="prose prose-sm text-text max-w-none memo-content">
{@html renderMd(memo.content || '')}
</div>
{/if}
<!-- 태그 -->
{#if (memo.user_tags?.length || memo.ai_tags?.length) && editingId !== memo.id}
<div class="flex flex-wrap gap-1.5 mt-3">
{#each memo.user_tags || [] as tag}
<button
onclick={() => filterByTag(tag)}
class="px-1.5 py-0.5 rounded text-[11px] bg-accent/10 text-accent hover:bg-accent/20 transition-colors"
>
#{tag}
</button>
{/each}
{#each memo.ai_tags || [] as tag}
<button
onclick={() => filterByTag(tag)}
class="px-1.5 py-0.5 rounded text-[11px] bg-surface text-dim hover:bg-surface-hover transition-colors"
>
{tag}
</button>
{/each}
</div>
{/if}
<!-- 하단: 시간 + 액션 -->
{#if editingId !== memo.id}
<div class="flex items-center justify-between mt-3 pt-2 border-t border-default">
<div class="flex items-center gap-2">
<span class="text-xs text-dim">{formatTime(memo.created_at)}</span>
{#if memo.ai_domain}
<span class="text-xs text-dim/70">| {memo.ai_domain}</span>
{/if}
{#if !memo.ask_includable}
<span class="text-[10px] text-dim/50 flex items-center gap-0.5">
<EyeOff size={10} /> AI 제외
</span>
{/if}
</div>
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onclick={() => togglePin(memo.id)}
class="p-1.5 rounded text-dim hover:text-accent hover:bg-surface transition-colors"
title={memo.pinned ? '핀 해제' : '핀 고정'}
>
{#if memo.pinned}
<PinOff size={14} />
{:else}
<Pin size={14} />
{/if}
</button>
<button
onclick={() => toggleAskIncludable(memo.id)}
class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors"
title={memo.ask_includable ? 'AI 답변에서 제외' : 'AI 답변에 포함'}
>
{#if memo.ask_includable}
<Eye size={14} />
{:else}
<EyeOff size={14} />
{/if}
</button>
<button
onclick={() => startEdit(memo)}
class="p-1.5 rounded text-dim hover:text-text hover:bg-surface transition-colors"
title="편집"
>
<Pencil size={14} />
</button>
<button
onclick={() => deleteMemo(memo.id)}
class="p-1.5 rounded text-dim hover:text-error hover:bg-error/10 transition-colors"
title="삭제"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/if}
</div>
</Card>
{/each}
</div>
<!-- 페이지네이션 -->
{#if total > 20}
<div class="flex justify-center gap-2 mt-6">
<Button
variant="ghost"
size="sm"
disabled={page <= 1}
onclick={() => { page -= 1; loadMemos(); }}
>
이전
</Button>
<span class="text-sm text-dim self-center">{page} / {Math.ceil(total / 20)}</span>
<Button
variant="ghost"
size="sm"
disabled={page * 20 >= total}
onclick={() => { page += 1; loadMemos(); }}
>
다음
</Button>
</div>
{/if}
{/if}
</div>
<style>
.memo-content :global(p) {
margin: 0.25em 0;
}
.memo-content :global(ul), .memo-content :global(ol) {
margin: 0.25em 0;
padding-left: 1.5em;
}
.memo-content :global(code) {
background: var(--bg);
padding: 0.1em 0.3em;
border-radius: 3px;
font-size: 0.85em;
}
.memo-content :global(pre) {
background: var(--bg);
padding: 0.75em;
border-radius: 6px;
overflow-x: auto;
margin: 0.5em 0;
}
.memo-content :global(a) {
color: var(--accent);
}
.memo-content :global(blockquote) {
border-left: 3px solid var(--border-default);
padding-left: 0.75em;
color: var(--text-dim);
margin: 0.5em 0;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
-- 105: 메모(note) 기능 지원
-- 메모 = file_type='note'인 document (파일 없는 문서)
-- file_hash는 note에서 content SHA-256 (파일 해시가 아닌 본문 버전 해시)
-- source_channel enum에 'memo' 추가 (유입 경로: 내장 메모 UI)
ALTER TYPE source_channel ADD VALUE IF NOT EXISTS 'memo';
-- file_path: NOT NULL 제거 (메모는 파일 없음)
ALTER TABLE documents ALTER COLUMN file_path DROP NOT NULL;
-- file_path: 기존 UNIQUE → partial unique (NULL 허용, 값 있으면 유니크)
DROP INDEX IF EXISTS documents_file_path_key;
CREATE UNIQUE INDEX IF NOT EXISTS uq_documents_file_path
ON documents(file_path) WHERE file_path IS NOT NULL;
-- user_tags: 사용자 수동 태그 (ai_tags와 분리, list[str])
ALTER TABLE documents ADD COLUMN IF NOT EXISTS user_tags JSONB DEFAULT '[]'::jsonb;
-- pinned: 메모 핀 고정
ALTER TABLE documents ADD COLUMN IF NOT EXISTS pinned BOOLEAN DEFAULT false;
-- ask_includable: /ask 합성 포함 여부 (false면 검색은 되지만 /ask evidence에서 제외)
ALTER TABLE documents ADD COLUMN IF NOT EXISTS ask_includable BOOLEAN DEFAULT true;
-- 메모 목록 최적화 인덱스 (핀 우선 + 최신순)
CREATE INDEX IF NOT EXISTS idx_documents_notes
ON documents(pinned DESC, created_at DESC)
WHERE file_type = 'note' AND deleted_at IS NULL;