diff --git a/app/api/memos.py b/app/api/memos.py index bc6ced2..f5fa749 100644 --- a/app/api/memos.py +++ b/app/api/memos.py @@ -74,6 +74,38 @@ def _toggle_task_line(content: str, target_index: int, checked: bool) -> tuple[s return "\n".join(lines), found +def _sync_task_state_with_content(content: str, existing_state: dict | None) -> dict: + """content 의 체크리스트 상태를 memo_task_state 와 동기화. + + - content 의 `- [x]` 중 state 에 checked_at 이 없으면 현재 시각으로 기록 + → 본문에 `- [x]` 로 직접 입력된 legacy 항목도 저장 시각 기준으로 10초 후 숨김 동작. + - content 의 `- [ ]` 에 해당하는 index 는 state 에서 제거. + - content 에 task 가 줄어들어 사라진 index 도 정리. + """ + state = dict(existing_state or {}) + current_keys: set[str] = set() + task_idx = 0 + now_iso = datetime.now(timezone.utc).isoformat() + for line in (content or "").split("\n"): + m = TASK_LINE_RE.match(line) + if not m: + continue + key = str(task_idx) + is_checked = m.group(2).lower() == "x" + if is_checked: + current_keys.add(key) + entry = state.get(key) or {} + if not entry.get("checked_at"): + state[key] = {"checked_at": now_iso} + # unchecked 는 current_keys 에 넣지 않음 → 아래에서 제거 + task_idx += 1 + # content 에서 unchecked 가 됐거나 아예 사라진 index 의 state 정리 + for k in list(state.keys()): + if k not in current_keys: + state.pop(k, None) + return state + + async def _enqueue_ai_stages(session: AsyncSession, document_id: int): """classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지).""" stages = ["classify", "embed", "chunk"] @@ -186,6 +218,8 @@ async def create_memo( pinned=False, archived=False, ask_includable=body.ask_includable, + # 본문에 `- [x]` 로 입력된 체크 항목도 생성 시각 기준 10초 후 자동 숨김 대상이 되도록 sync. + memo_task_state=_sync_task_state_with_content(content, None), ) session.add(doc) await session.flush() @@ -277,6 +311,9 @@ async def update_memo( doc.extracted_text = content doc.file_hash = _content_hash(content) doc.file_size = len(content.encode("utf-8")) + # 본문 편집으로 task 순서/추가/삭제가 일어났을 수 있으니 state 재동기화. + # `- [x]` 에 checked_at 없으면 이번 수정 시각으로 기록 → 10초 후 자동 숨김 동작. + doc.memo_task_state = _sync_task_state_with_content(content, doc.memo_task_state) # PATCH semantics: title 필드를 명시적으로 보낸 경우만 덮어쓴다. # 체크박스 토글 경로처럼 {content}만 PATCH 하면 기존 title을 보존해야 함 # (이전엔 None→_auto_title(content)로 제목이 체크박스 라인으로 덮어씌워지는 버그). diff --git a/scripts/backfill_memo_task_state.py b/scripts/backfill_memo_task_state.py new file mode 100644 index 0000000..a7596cb --- /dev/null +++ b/scripts/backfill_memo_task_state.py @@ -0,0 +1,123 @@ +"""memo_task_state backfill — 기존 메모의 `- [x]` 항목에 checked_at 채우기. + +feat(memo) auto-hide 배포 이전에 만들어진 note 들은 memo_task_state = '{}' 인 상태. +본문에 `- [x]` 로 저장된 체크 항목이 있어도 checked_at 이 없어 자동 숨김이 동작하지 않음. +이 스크립트는 모든 file_type='note' 행을 돌며 content 와 state 를 동기화한다 +(배포 시각 = checked_at 으로 기록 → 배포 직후 10초 내 순차 숨김). + +실행: + docker compose exec fastapi python /app/scripts/backfill_memo_task_state.py --dry-run + docker compose exec fastapi python /app/scripts/backfill_memo_task_state.py --apply +""" + +import argparse +import asyncio +import os +import re +import sys +from datetime import datetime, timezone + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +TASK_LINE_RE = re.compile(r"^(\s*- \[)([ xX])(\].*)$") + + +def sync_task_state(content: str, existing_state: dict | None, now_iso: str) -> dict: + """memos.py 의 _sync_task_state_with_content 와 동일 로직 (스크립트 독립 실행용 복제).""" + state = dict(existing_state or {}) + current_keys: set[str] = set() + task_idx = 0 + for line in (content or "").split("\n"): + m = TASK_LINE_RE.match(line) + if not m: + continue + key = str(task_idx) + is_checked = m.group(2).lower() == "x" + if is_checked: + current_keys.add(key) + entry = state.get(key) or {} + if not entry.get("checked_at"): + state[key] = {"checked_at": now_iso} + task_idx += 1 + for k in list(state.keys()): + if k not in current_keys: + state.pop(k, None) + return state + + +async def run(apply: bool) -> int: + database_url = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://pkm:pkm@postgres:5432/pkm", + ) + engine = create_async_engine(database_url) + session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + now_iso = datetime.now(timezone.utc).isoformat() + + async with session_factory() as session: + rows = ( + await session.execute( + text( + """ + SELECT id, extracted_text, memo_task_state + FROM documents + WHERE file_type = 'note' + AND deleted_at IS NULL + """ + ) + ) + ).all() + print(f"총 note 대상 {len(rows)}건 스캔") + + to_update: list[tuple[int, dict, dict]] = [] + for r in rows: + old_state = dict(r.memo_task_state or {}) + new_state = sync_task_state(r.extracted_text or "", old_state, now_iso) + if new_state != old_state: + to_update.append((r.id, old_state, new_state)) + + print(f"변경 예정: {len(to_update)}건") + sample = to_update[:5] + for memo_id, old, new in sample: + added = {k: v for k, v in new.items() if k not in old} + removed = [k for k in old.keys() if k not in new] + print(f" id={memo_id} added={list(added.keys())} removed={removed}") + + if not apply: + print("\n--dry-run (변경 없음). 실제 적용하려면 --apply") + await engine.dispose() + return 0 + + if not to_update: + print("변경 대상 없음.") + await engine.dispose() + return 0 + + import json + for memo_id, _old, new in to_update: + await session.execute( + text( + "UPDATE documents SET memo_task_state = CAST(:s AS jsonb) WHERE id = :id" + ), + {"s": json.dumps(new), "id": memo_id}, + ) + await session.commit() + print(f"\n{len(to_update)}건 반영 완료 (checked_at={now_iso})") + + await engine.dispose() + return 0 + + +def main() -> int: + p = argparse.ArgumentParser() + g = p.add_mutually_exclusive_group(required=True) + g.add_argument("--dry-run", action="store_true") + g.add_argument("--apply", action="store_true") + args = p.parse_args() + return asyncio.run(run(apply=args.apply)) + + +if __name__ == "__main__": + sys.exit(main())