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)
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user