Files
hyungi_document_server/scripts/backfill_memo_task_state.py
T
Hyungi Ahn 8427ac886c feat(memo): sync content ↔ memo_task_state on create/update + backfill script
본문에 `- [x]` 로 직접 입력된 체크 항목도 checked_at 가 기록되어 10초 후
자동 숨김 대상이 되도록 create_memo / update_memo 에 sync 로직 추가.

- _sync_task_state_with_content: - [x] 에 checked_at 없으면 현재 시각으로 기록,
  - [ ] 또는 사라진 index 는 state 에서 정리
- scripts/backfill_memo_task_state.py: 배포 이전 기존 노트에 현재 시각 backfill
  (docker compose exec fastapi python /app/scripts/backfill_memo_task_state.py --apply)
2026-04-24 15:40:18 +09:00

124 lines
4.2 KiB
Python

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