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