diff --git a/app/api/memos.py b/app/api/memos.py index 0fd4cf5..bc6ced2 100644 --- a/app/api/memos.py +++ b/app/api/memos.py @@ -1,6 +1,7 @@ """메모 CRUD API — 파일 없는 문서(file_type='note')""" import hashlib +import logging import re from datetime import datetime, timezone from typing import Annotated @@ -16,8 +17,13 @@ from models.document import Document from models.queue import ProcessingQueue, enqueue_stage from models.user import User +logger = logging.getLogger(__name__) + router = APIRouter() +# markdown task line: "- [ ] ..." 또는 "- [x] ..." +TASK_LINE_RE = re.compile(r"^(\s*- \[)([ xX])(\].*)$") + # #태그 파싱 패턴: 한글/영문/숫자/밑줄, 2자 이상 TAG_PATTERN = re.compile(r"(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})") @@ -46,6 +52,28 @@ def _auto_title(content: str) -> str: return title +def _toggle_task_line(content: str, target_index: int, checked: bool) -> tuple[str, bool]: + """N번째 markdown task line을 찾아 checked/unchecked 상태로 설정. + + (new_content, found) 반환. found=False면 target_index에 해당하는 task line이 없음 + (본문 편집으로 drift된 경우). + """ + lines = content.split("\n") + ti = 0 + found = False + for i, line in enumerate(lines): + m = TASK_LINE_RE.match(line) + if not m: + continue + if ti == target_index: + mark = "x" if checked else " " + lines[i] = m.group(1) + mark + m.group(3) + found = True + break + ti += 1 + return "\n".join(lines), found + + async def _enqueue_ai_stages(session: AsyncSession, document_id: int): """classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지).""" stages = ["classify", "embed", "chunk"] @@ -78,6 +106,10 @@ class ArchiveSet(BaseModel): archived: bool +class TaskToggle(BaseModel): + checked: bool + + class MemoResponse(BaseModel): id: int title: str | None @@ -91,6 +123,7 @@ class MemoResponse(BaseModel): pinned: bool archived: bool ask_includable: bool + memo_task_state: dict # {"": {"checked_at": ""}} created_at: datetime updated_at: datetime @@ -119,6 +152,7 @@ def _to_memo_response(doc: Document) -> MemoResponse: pinned=doc.pinned, archived=doc.archived, ask_includable=doc.ask_includable, + memo_task_state=dict(doc.memo_task_state or {}), created_at=doc.created_at, updated_at=doc.updated_at, ) @@ -279,6 +313,68 @@ async def update_memo( return _to_memo_response(doc) +@router.patch("/{memo_id}/tasks/{task_index}", response_model=MemoResponse) +async def toggle_memo_task( + memo_id: int, + task_index: int, + body: TaskToggle, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """메모 체크박스 토글 전용 엔드포인트. + + N번째 markdown task line의 체크 상태를 설정하고 memo_task_state에 시각 기록. + AI 재처리(classify/embed/chunk)는 **의도적으로 스킵** — 체크박스 한 번에 재분석을 트리거하는 건 과하다. + 같은 row를 동시에 토글하는 race 방지를 위해 SELECT ... FOR UPDATE 사용. + """ + # ❶ FOR UPDATE: 같은 row 동시 토글 race 차단 (JSONB 전체 replace라 필수) + doc = await session.get(Document, memo_id, with_for_update=True) + if not doc or doc.file_type != "note" or doc.deleted_at is not None: + raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") + + state = dict(doc.memo_task_state or {}) + key = str(task_index) + + # ❷ content의 N번째 task line 토글 + new_content, found = _toggle_task_line(doc.extracted_text or "", task_index, body.checked) + if not found: + # drift: 사용자가 본문 편집으로 task_index 매칭이 깨짐 → stale state만 정리하고 200 OK + stale_removed = key in state + if stale_removed: + state.pop(key, None) + doc.memo_task_state = state + await session.commit() + await session.refresh(doc) + logger.info( + "memo_task_toggle_drift memo_id=%s task_index=%s stale_removed=%s", + memo_id, task_index, stale_removed, + ) + return _to_memo_response(doc) + + doc.extracted_text = new_content + doc.file_hash = _content_hash(new_content) + doc.file_size = len(new_content.encode("utf-8")) + + # ❸ task_state 갱신 (JSONB 전체 replace — FOR UPDATE lock 아래라 race safe) + if body.checked: + state[key] = {"checked_at": datetime.now(timezone.utc).isoformat()} + else: + state.pop(key, None) + doc.memo_task_state = state + + doc.updated_at = datetime.now(timezone.utc) + # AI 재처리 / user_tags 재파싱 / chunks 삭제 / queue enqueue — 모두 의도적 스킵. + # 왜 스킵하는지 나중에 디버깅하지 않아도 되도록 명시 로그. + logger.info( + "memo_task_toggle_skip_ai memo_id=%s task_index=%s checked=%s", + memo_id, task_index, body.checked, + ) + + 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, diff --git a/app/models/document.py b/app/models/document.py index e877f3e..fcfaa84 100644 --- a/app/models/document.py +++ b/app/models/document.py @@ -67,6 +67,10 @@ class Document(Base): # 아카이브 (현재 메모 UX 전용, 문서 쪽에는 노출하지 않음) archived: Mapped[bool] = mapped_column(Boolean, default=False) + # 메모 체크박스별 메타 — {"": {"checked_at": ""}} + # UI에서 체크 후 10초 경과 항목 숨김 판정에 사용. file_type='note'에서만 의미 있음. + memo_task_state: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + # ODF 변환 derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/) original_format: Mapped[str | None] = mapped_column(String(20)) diff --git a/frontend/src/lib/utils/memoRenderer.ts b/frontend/src/lib/utils/memoRenderer.ts index 78108b0..e4995a5 100644 --- a/frontend/src/lib/utils/memoRenderer.ts +++ b/frontend/src/lib/utils/memoRenderer.ts @@ -30,8 +30,16 @@ const CHECKBOX_INPUT_RE = /]*type="checkbox")(?=[^>]*disabled)[^>] export interface RenderOptions { compact?: boolean; interactive?: boolean; + /** 서버에서 내려온 memo_task_state — {"": {"checked_at": ""}} */ + taskStates?: Record; + /** 현재 시각 tick. 지정 시 checked_at 경과 항목에 task-hidden 클래스 부여. */ + now?: Date; + /** 체크 후 자동 숨김까지 대기 시간 (ms). 기본 10초. */ + hideAfterMs?: number; } +export const DEFAULT_HIDE_AFTER_MS = 10_000; + /** * 메모 마크다운 → HTML 변환. * - GFM task list checkbox를 인터랙티브하게 교체 @@ -39,7 +47,12 @@ export interface RenderOptions { * - #태그를 클릭 가능 링크로 변환 */ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): string { - const { interactive = true } = options; + const { + interactive = true, + taskStates = {}, + now, + hideAfterMs = DEFAULT_HIDE_AFTER_MS, + } = options; // 1. #태그 → 링크 (marked 전에 처리, 마크다운 파싱 방해 않는 위치) let text = markdown.replace( @@ -50,21 +63,33 @@ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): s // 2. marked → HTML let html = marked(text) as string; - // 3. checkbox input 교체 (인터랙티브 + data-task-index) + // 3. checkbox input 교체 (인터랙티브 + data-task-index + data-checked-at) let taskIdx = 0; html = html.replace(CHECKBOX_INPUT_RE, (match) => { const idx = taskIdx++; const isChecked = /checked/.test(match); + const checkedAt = taskStates[String(idx)]?.checked_at ?? ''; + const dataCheckedAt = checkedAt ? ` data-checked-at="${checkedAt}"` : ''; if (interactive) { - return ``; + return ``; } - return ``; + return ``; }); - // 4. checkbox line의 due date badge 변환 - //
  • 태그 내부에서만 @YYYY-MM-DD를 badge로 교체 + // 4. checkbox line의 due date badge 변환 + 자동 숨김 클래스 + const nowMs = now ? now.getTime() : Date.now(); html = html.replace(/
  • (]*class="memo-checkbox"[^>]*>)(.*?)<\/li>/gi, (fullMatch, inputTag, rest) => { const isChecked = /checked/.test(inputTag); + // checked_at 추출 → 10초 경과 판정 + const checkedAtMatch = /data-checked-at="([^"]+)"/.exec(inputTag); + let hidden = false; + if (isChecked && checkedAtMatch) { + const checkedAtMs = Date.parse(checkedAtMatch[1]); + if (!Number.isNaN(checkedAtMs) && nowMs - checkedAtMs >= hideAfterMs) { + hidden = true; + } + } + // 마지막 @YYYY-MM-DD만 badge로 변환 let lastDue: ParsedDueDate | null = null; let lastMatch: { index: number; length: number } | null = null; @@ -78,18 +103,22 @@ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): s } } + const classes = [ + isChecked ? 'memo-task-done' : '', + hidden ? 'memo-task-hidden' : '', + ].filter(Boolean).join(' '); + const classAttr = classes ? ` class="${classes}"` : ''; + if (lastDue && lastMatch) { const status = isChecked ? 'normal' : getDueStatus(lastDue.date); const badgeClass = getDueBadgeClass(status, isChecked); const label = formatDueLabel(lastDue.date, status); const badge = `${label}`; const newRest = rest.slice(0, lastMatch.index) + badge + rest.slice(lastMatch.index + lastMatch.length); - const liClass = isChecked ? 'memo-task-done' : ''; - return `
  • ${inputTag}${newRest}
  • `; + return `${inputTag}${newRest}`; } - const liClass = isChecked ? 'memo-task-done' : ''; - return `
  • ${inputTag}${rest}
  • `; + return `${inputTag}${rest}`; }); // 5. DOMPurify sanitize @@ -99,10 +128,56 @@ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): s FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false, ADD_TAGS: ['input'], - ADD_ATTR: ['type', 'checked', 'data-task-index', 'class'], + ADD_ATTR: ['type', 'checked', 'data-task-index', 'data-checked-at', 'class'], }); } +/** + * 체크 후 hideAfterMs 이내에 다시 가려져야 할 항목의 남은 시간(ms) 중 최소값을 반환. + * 다음 tick 타이머를 정확히 예측하는 데 사용. 가려질 항목이 없으면 null. + */ +export function nextHideInMs( + taskStates: Record | undefined | null, + now: Date, + hideAfterMs = DEFAULT_HIDE_AFTER_MS, +): number | null { + if (!taskStates) return null; + const nowMs = now.getTime(); + let soonest: number | null = null; + for (const key of Object.keys(taskStates)) { + const at = taskStates[key]?.checked_at; + if (!at) continue; + const atMs = Date.parse(at); + if (Number.isNaN(atMs)) continue; + const remain = atMs + hideAfterMs - nowMs; + if (remain > 0 && (soonest === null || remain < soonest)) { + soonest = remain; + } + } + return soonest; +} + +/** + * 메모에 표시할 "숨긴 체크 항목 개수". 토글 버튼 라벨용. + */ +export function countHiddenTasks( + taskStates: Record | undefined | null, + now: Date, + hideAfterMs = DEFAULT_HIDE_AFTER_MS, +): number { + if (!taskStates) return 0; + const nowMs = now.getTime(); + let n = 0; + for (const key of Object.keys(taskStates)) { + const at = taskStates[key]?.checked_at; + if (!at) continue; + const atMs = Date.parse(at); + if (Number.isNaN(atMs)) continue; + if (nowMs - atMs >= hideAfterMs) n++; + } + return n; +} + /** * n번째 task item의 checkbox 토글. 일반 줄은 카운트하지 않음. */ diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index c4d0a56..9d82145 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -18,7 +18,7 @@ Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight, Pencil, Library, Mic, Video, Sparkles, } from 'lucide-svelte'; - import { renderMemoHtml, toggleTaskLine } from '$lib/utils/memoRenderer'; + import { renderMemoHtml, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer'; import { addToast } from '$lib/stores/toast'; let summary = $derived($dashboardSummary); @@ -26,6 +26,16 @@ // ─── 핀 고정 메모 ─── let pinnedMemos = $state([]); + // 메모별 "완료 항목 펼침" 토글 — key: memo.id, value: true 면 숨겨진 체크 항목 노출 + let showHiddenByMemo = $state>({}); + // 자동 숨김 tick. 1초 해상도로 충분 (hideAfter 10초라 오차 수용). + let nowTick = $state(new Date()); + + $effect(() => { + const id = setInterval(() => { nowTick = new Date(); }, 1000); + return () => clearInterval(id); + }); + onMount(async () => { try { const res = await api('/memos/?pinned=true&page_size=3&archived=false'); @@ -39,26 +49,27 @@ if (target.tagName !== 'INPUT' || (target as HTMLInputElement).type !== 'checkbox') return; e.preventDefault(); e.stopPropagation(); // details 토글 충돌 방지 - const taskIndex = parseInt((target as HTMLElement).dataset.taskIndex || '', 10); + const input = target as HTMLInputElement; + const taskIndex = parseInt(input.dataset.taskIndex || '', 10); if (isNaN(taskIndex)) return; - const oldContent = memo.content; - const newContent = toggleTaskLine(oldContent, taskIndex); - memo.content = newContent; - pinnedMemos = [...pinnedMemos]; - + const checked = input.checked; try { - await api(`/memos/${memo.id}`, { + const updated = await api(`/memos/${memo.id}/tasks/${taskIndex}`, { method: 'PATCH', - body: JSON.stringify({ content: newContent }), + body: JSON.stringify({ checked }), }); + pinnedMemos = pinnedMemos.map((m) => (m.id === memo.id ? updated : m)); } catch { - memo.content = oldContent; - pinnedMemos = [...pinnedMemos]; + input.checked = !checked; // 롤백 addToast('error', '체크박스 변경 실패'); } } + function toggleShowHidden(memoId: number) { + showHiddenByMemo = { ...showHiddenByMemo, [memoId]: !showHiddenByMemo[memoId] }; + } + // ─── 파이프라인 ─── const STAGE_ORDER = ['extract', 'stt', 'classify', 'embed', 'preview', 'thumbnail'] as const; const STAGE_LABEL: Record = { @@ -204,10 +215,31 @@ class="mt-1 px-3 py-2.5 bg-surface/50 border border-default/30 rounded-lg text-sm text-text" onclick={(e) => handlePinCheckbox(e, memo)} > -
    - {@html renderMemoHtml(memo.content || '', { compact: true, interactive: true })} +
    + {@html renderMemoHtml(memo.content || '', { + compact: true, + interactive: true, + taskStates: memo.memo_task_state ?? {}, + now: nowTick, + })}
    + {#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]} + + {/if} 메모함에서 보기 →
    @@ -508,6 +540,9 @@ .memo-content-pin :global(.memo-checkbox) { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent); vertical-align: middle; margin-right: 3px; } .memo-content-pin :global(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; } .memo-content-pin :global(.memo-task-done) { opacity: 0.5; text-decoration: line-through; } + /* 체크 후 10초 경과 항목 자동 숨김 (`show-hidden` 클래스로 토글 해제) */ + .memo-content-pin :global(.memo-task-hidden) { display: none; } + .memo-content-pin.show-hidden :global(.memo-task-hidden) { display: list-item; } .memo-content-pin :global(.due-badge) { display: inline-block; font-size: 10px; padding: 1px 5px; border-radius: 8px; margin-left: 3px; } .memo-content-pin :global(.due-overdue) { background: rgba(245, 86, 78, 0.15); color: var(--error); } .memo-content-pin :global(.due-soon) { background: rgba(251, 191, 36, 0.15); color: var(--warning); } diff --git a/frontend/src/routes/memos/+page.svelte b/frontend/src/routes/memos/+page.svelte index e807f3e..249f335 100644 --- a/frontend/src/routes/memos/+page.svelte +++ b/frontend/src/routes/memos/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { renderMemoHtml, toggleTaskLine, todayIso } from '$lib/utils/memoRenderer'; + import { renderMemoHtml, todayIso, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer'; import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays } from 'lucide-svelte'; import Button from '$lib/components/ui/Button.svelte'; import Card from '$lib/components/ui/Card.svelte'; @@ -31,6 +31,19 @@ let activeTag = $state(null); let showArchived = $state(false); + // 자동 숨김 tick (1초 해상도) + 메모별 "숨겨진 항목 펼침" 상태 + let nowTick = $state(new Date()); + let showHiddenByMemo = $state({}); + + $effect(() => { + const id = setInterval(() => { nowTick = new Date(); }, 1000); + return () => clearInterval(id); + }); + + function toggleShowHidden(memoId) { + showHiddenByMemo = { ...showHiddenByMemo, [memoId]: !showHiddenByMemo[memoId] }; + } + onMount(() => { const params = new URLSearchParams(window.location.search); activeTag = params.get('tag'); @@ -243,20 +256,15 @@ const taskIndex = parseInt(target.dataset.taskIndex, 10); if (isNaN(taskIndex)) return; - const oldContent = memo.content; - const newCont = toggleTaskLine(oldContent, taskIndex); - memo.content = newCont; - memos = [...memos]; - + const checked = target.checked; try { - const updated = await api(`/memos/${memo.id}`, { + const updated = await api(`/memos/${memo.id}/tasks/${taskIndex}`, { method: 'PATCH', - body: JSON.stringify({ content: newCont }), + body: JSON.stringify({ checked }), }); memos = memos.map((m) => (m.id === memo.id ? updated : m)); } catch (err) { - memo.content = oldContent; - memos = [...memos]; + target.checked = !checked; // 롤백 addToast('error', '체크박스 변경 실패'); } } @@ -472,10 +480,27 @@
    handleCheckboxClick(e, memo)} > - {@html renderMemoHtml(memo.content || '')} + {@html renderMemoHtml(memo.content || '', { + taskStates: memo.memo_task_state ?? {}, + now: nowTick, + })}
    + {#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]} + + {/if} {/if} @@ -550,6 +575,9 @@ } .memo-content :global(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; } .memo-content :global(.memo-task-done) { opacity: 0.5; text-decoration: line-through; } + /* 체크 후 10초 경과 항목 자동 숨김 (.show-hidden 클래스로 해제) */ + .memo-content :global(.memo-task-hidden) { display: none; } + .memo-content.show-hidden :global(.memo-task-hidden) { display: list-item; } .memo-content :global(.due-badge) { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 8px; margin-left: 4px; vertical-align: middle; } .memo-content :global(.due-overdue) { background: rgba(245, 86, 78, 0.15); color: var(--error); } .memo-content :global(.due-soon) { background: rgba(251, 191, 36, 0.15); color: var(--warning); } diff --git a/migrations/161_memo_task_state.sql b/migrations/161_memo_task_state.sql new file mode 100644 index 0000000..fc37124 --- /dev/null +++ b/migrations/161_memo_task_state.sql @@ -0,0 +1,3 @@ +-- 156: 메모 체크박스 개별 항목의 체크 시각 저장 (자동 숨김용) +-- 구조: {"": {"checked_at": ""}} +ALTER TABLE documents ADD COLUMN IF NOT EXISTS memo_task_state JSONB NOT NULL DEFAULT '{}'::jsonb