"""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())