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:
141
scripts/migrate_comment_md_to_notes.py
Normal file
141
scripts/migrate_comment_md_to_notes.py
Normal 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()
|
||||
Reference in New Issue
Block a user