chore(scripts): migration Phase A — backfill comment notes

Walk .state/comments/, attach combined.md (rendered HTML) as a
single 'Comment text' bib note per item. Idempotent; --cleanup
flag for the destructive Phase C lands next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kert
2026-04-29 10:13:07 -04:00
parent 7c2d26ec57
commit beacbd743f

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python
"""One-shot: comment .md attachments → Zotero notes.
Phases:
A (default): for every .state/comments/<docket>/<id>/combined.md,
attach a single 'Comment text' note (rendered HTML)
to the bib item. Idempotent; safe to retry.
C (--cleanup): destructively delete every .md row from bib.attachments,
matching itemAttachments + items rows in Zotero, and the
per-attachment storage dirs. Prompts before destruction.
Phase B (push notes to Zotero) lives outside this script — operator runs
'stack bib sync' between A and C.
After a successful migration, this file should be removed in a follow-up
commit.
"""
from __future__ import annotations
import argparse
import logging
import shutil
import sqlite3
import sys
from pathlib import Path
logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO)
log = logging.getLogger("migrate-comments-md-to-notes")
COMMENTS_ROOT = Path(".state/comments")
from cli.comments import NOTE_TITLE # noqa: E402 — after stdlib imports
def _safety_preflight() -> None:
"""Refuse to run unless _sync_notes is on disk — sanity check that
re-syncing won't recreate the same problem we're cleaning up."""
sync_py = Path("src/bib/sync.py")
if not sync_py.is_file():
sys.exit("FATAL: src/bib/sync.py missing — run from repo root.")
if "_sync_notes" not in sync_py.read_text(encoding="utf-8"):
sys.exit(
"FATAL: src/bib/sync.py has no _sync_notes — apply Task 3 first."
)
def phase_a_backfill(root: Path) -> dict[str, int]:
"""Walk comment dirs, attach combined.md as a 'Comment text' note."""
from bib import connect
from rex.comments.render import render_combined_md
store = connect()
con = store._con() # noqa: SLF001
# comment_id → bib item_key
keys: dict[str, str] = {
(row[1] or "").rsplit("/", 1)[-1]: row[0]
for row in con.execute(
"SELECT key, url FROM items "
"WHERE url LIKE 'https://www.regulations.gov/comment/%'"
)
}
# item_keys that already have a 'Comment text' note → skip
have_note: set[str] = {
row[0]
for row in con.execute(
"SELECT i.key FROM notes n "
"JOIN items i ON n.item_id = i.id "
"WHERE n.title = ?",
(NOTE_TITLE,),
)
}
stats = {"scanned": 0, "attached": 0, "skipped_have_note": 0,
"skipped_no_combined": 0, "skipped_unknown_item": 0,
"errors": 0}
for combined in root.rglob("combined.md"):
stats["scanned"] += 1
comment_id = combined.parent.name
item_key = keys.get(comment_id)
if not item_key:
stats["skipped_unknown_item"] += 1
continue
if item_key in have_note:
stats["skipped_have_note"] += 1
continue
try:
html = render_combined_md(combined.read_text(encoding="utf-8"))
store.attach_note(item_key, html, title=NOTE_TITLE)
have_note.add(item_key)
stats["attached"] += 1
except Exception as e: # noqa: BLE001
log.warning("attach_note failed for %s: %s", item_key, e)
stats["errors"] += 1
if stats["scanned"] % 1000 == 0:
log.info("phase A progress: %s", stats)
return stats
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--root", type=Path, default=COMMENTS_ROOT,
help="Comments root (default: .state/comments)",
)
parser.add_argument(
"--cleanup", action="store_true",
help="After Phase A, also run Phase C (destructive: deletes all "
"*.md rows from bib + Zotero and removes their storage dirs).",
)
args = parser.parse_args()
_safety_preflight()
if not args.root.is_dir():
sys.exit(f"FATAL: comments root not found: {args.root}")
log.info("Phase A: backfilling notes from %s", args.root)
stats_a = phase_a_backfill(args.root)
log.info("Phase A complete: %s", stats_a)
if not args.cleanup:
log.info(
"Done. Run 'stack bib sync' to push the notes to Zotero, "
"then re-run with --cleanup to delete the old .md attachments."
)
return
# Phase C lands in Task 6.
log.warning("--cleanup not yet implemented; landing in Task 6")
if __name__ == "__main__":
main()