8 tasks: deps → renderer → bib/sync notes → cli/comments flip → migration phase A → migration phase C → execute migration → delete migration script. TDD per code task. Companion to the spec at .claude/specs/2026-04-28-comments-zotero-notes-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
34 KiB
Comments → Zotero Notes Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the current .md file attachment output (33,351 attachments / 23,605 items) with rendered-HTML Zotero notes, so extracted comment text is readable directly in Zotero's reader pane. New code path produces notes; one-shot migration retires the old file attachments.
Architecture: Three new code units — a markdown→HTML renderer (rex/comments/render.py), note-syncing in bib/sync.py, and a flipped attach_callback in cli/comments.py. Plus a one-shot migration script that backfills notes for existing comment dirs and destructively cleans up the old .md attachment rows + storage dirs in both bib and Zotero.
Tech Stack: Python 3.12, markdown-it-py + mdit-py-plugins (rendering), SQLite (bib + Zotero), pytest. Spec: .claude/specs/2026-04-28-comments-zotero-notes-design.md.
Task 1: Add markdown rendering deps
Files:
-
Modify:
pyproject.toml -
Step 1: Add deps to the
bibextra
In pyproject.toml, update the bib extra:
bib = [
"stack[conf]",
"pydantic>=2.0.0",
"markdown-it-py>=4.0",
"mdit-py-plugins>=0.4",
]
- Step 2: Sync deps
Run: uv sync --extra bib
Expected: succeeds; markdown-it-py and mdit-py-plugins move from transitive to direct.
- Step 3: Smoke-import
Run: uv run python -c "from markdown_it import MarkdownIt; from mdit_py_plugins.front_matter import front_matter_plugin; print('ok')"
Expected: ok
- Step 4: Commit
git add pyproject.toml uv.lock
git commit -m "$(cat <<'EOF'
deps(bib): promote markdown-it-py + mdit-py-plugins to direct deps
Used by upcoming rex.comments.render to convert combined.md to HTML
for Zotero notes. Both were already transitive in uv.lock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 2: Renderer module + tests
Files:
-
Create:
src/rex/comments/render.py -
Create:
tests/rex/comments/test_render.py -
Step 1: Write failing tests
Create tests/rex/comments/test_render.py:
"""rex.comments.render — combined.md → Zotero-edible HTML."""
from __future__ import annotations
from rex.comments.render import render_combined_md
def test_strips_frontmatter():
md = (
"---\n"
"comment_id: CMS-2024-0001-0001\n"
"status: ok\n"
"---\n"
"\n"
"# Heading\n"
"\n"
"body\n"
)
html = render_combined_md(md)
assert "comment_id" not in html
assert "status: ok" not in html
assert "<h1>Heading</h1>" in html
assert "<p>body</p>" in html
def test_renders_basic_markdown():
md = (
"---\nstatus: ok\n---\n\n"
"## Section\n\n"
"Text with **bold** and *italic*.\n"
)
html = render_combined_md(md)
assert "<h2>Section</h2>" in html
assert "<strong>bold</strong>" in html
assert "<em>italic</em>" in html
def test_renders_tables():
md = (
"---\nstatus: ok\n---\n\n"
"| Col A | Col B |\n"
"| --- | --- |\n"
"| a1 | b1 |\n"
"| a2 | b2 |\n"
)
html = render_combined_md(md)
assert "<table>" in html
assert "<th>Col A</th>" in html
assert "<td>a1</td>" in html
def test_handles_empty_body():
md = "---\nstatus: ok\n---\n\n"
html = render_combined_md(md)
assert html == "" or html.strip() == ""
def test_raises_on_missing_frontmatter():
"""combined.md is always written with frontmatter — guard against
silently ingesting malformed input."""
import pytest
with pytest.raises(ValueError, match="frontmatter"):
render_combined_md("# No frontmatter here\n")
- Step 2: Run tests to verify they fail
Run: uv run pytest tests/rex/comments/test_render.py -v
Expected: ImportError / "No module named 'rex.comments.render'" — all fail.
- Step 3: Implement the renderer
Create src/rex/comments/render.py:
"""Render combined.md → HTML for Zotero notes.
combined.md is the per-comment aggregate written by ``combine.extract_comment``:
a YAML frontmatter block followed by an inline-body section and one H2 section
per attachment. Zotero notes accept HTML, so we strip the frontmatter and run
the body through markdown-it-py.
"""
from __future__ import annotations
from markdown_it import MarkdownIt
from rex.comments.combine import parse_combined
_MD = MarkdownIt("commonmark", {"html": False, "breaks": False, "linkify": True})
_MD.enable("table")
def render_combined_md(text: str) -> str:
"""Strip combined.md's YAML frontmatter, render body to HTML.
Raises ValueError if the input is missing frontmatter — combined.md
always has it, so a missing block means the input isn't combined.md.
"""
_, body = parse_combined(text)
return _MD.render(body)
- Step 4: Run tests to verify they pass
Run: uv run pytest tests/rex/comments/test_render.py -v
Expected: all 5 tests pass.
- Step 5: Commit
git add src/rex/comments/render.py tests/rex/comments/test_render.py
git commit -m "$(cat <<'EOF'
feat(rex/comments): render combined.md → HTML for Zotero notes
Pure function over (str)→str. Reuses combine.parse_combined to peel
off the YAML frontmatter, runs the body through markdown-it-py with
table support enabled (CMS comments lean on tabular data).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 3: Note-syncing in bib/sync.py
Files:
-
Modify:
src/bib/sync.py -
Modify:
tests/bib/test_sync.py:17-103(ZOTERO_SCHEMA — additemNotestable) -
Modify:
tests/bib/test_sync.py(add new test classTestSyncNotes) -
Step 1: Extend test schema with itemNotes
In tests/bib/test_sync.py, append to ZOTERO_SCHEMA (after the creatorTypes block, around line 102):
CREATE TABLE IF NOT EXISTS itemNotes (
itemID INTEGER PRIMARY KEY,
parentItemID INT,
note TEXT,
title TEXT
);
CREATE TABLE IF NOT EXISTS itemAttachments (
itemID INTEGER PRIMARY KEY,
parentItemID INT,
linkMode INT,
contentType TEXT,
charsetID INT,
path TEXT,
syncState INT DEFAULT 0,
storageModTime INT,
storageHash TEXT
);
(Tests for sync up to now didn't exercise children — itemAttachments is added here too because the new branch in _sync_attachments also references it during update flow.)
- Step 2: Write failing tests
Append to tests/bib/test_sync.py:
class TestSyncNotes:
"""bib notes → Zotero itemNotes (non-attachment children)."""
def _setup_bib_with_note(self, tmp_path, *, html: str, title: str):
from bib import connect
from bib.item import Source
bib_db = tmp_path / "bib.sqlite"
store = connect(str(bib_db))
item = Source(
title="Comment CMS-2024-0001-0001",
url="https://www.regulations.gov/comment/CMS-2024-0001-0001",
)
item_key = store.upsert(item)
store.attach_note(item_key, html, title=title)
store.close()
return bib_db, item_key
def test_note_creates_zotero_itemnote(self, tmp_path):
bib_db, item_key = self._setup_bib_with_note(
tmp_path, html="<h1>Body</h1><p>text</p>", title="Comment text"
)
zot_db = str(tmp_path / "zotero.sqlite")
con = _make_zotero_db(zot_db)
con.close()
from bib import connect
store = connect(str(bib_db))
items = store.list_items(tag=None)
stats = push_to_zotero(items, store=store, zotero_db=zot_db)
store.close()
assert stats["notes"] == 1
con = sqlite3.connect(zot_db)
rows = con.execute(
"SELECT title, note FROM itemNotes"
).fetchall()
con.close()
assert rows == [("Comment text", "<h1>Body</h1><p>text</p>")]
def test_resync_is_idempotent(self, tmp_path):
"""Second sync must not duplicate the note (dedup by parent + title)."""
bib_db, item_key = self._setup_bib_with_note(
tmp_path, html="<p>x</p>", title="Comment text"
)
zot_db = str(tmp_path / "zotero.sqlite")
con = _make_zotero_db(zot_db)
con.close()
from bib import connect
store = connect(str(bib_db))
items = store.list_items(tag=None)
push_to_zotero(items, store=store, zotero_db=zot_db)
stats = push_to_zotero(items, store=store, zotero_db=zot_db)
store.close()
assert stats["notes"] == 0 # second pass, nothing new
con = sqlite3.connect(zot_db)
n = con.execute("SELECT COUNT(*) FROM itemNotes").fetchone()[0]
con.close()
assert n == 1
- Step 3: Run tests to verify they fail
Run: uv run pytest tests/bib/test_sync.py::TestSyncNotes -v
Expected: failures — KeyError: 'notes' (stats dict has no notes key) and assertion failures.
- Step 4: Implement
_sync_notesin bib/sync.py
Add at the end of src/bib/sync.py (after _sync_attachments):
def _sync_notes(
db: Db,
store: Store,
bib_item: Item,
zot_parent_id: int,
) -> int:
"""Push every bib note for ``bib_item`` into Zotero's ``itemNotes``.
Idempotent on (parentItemID, title): an existing same-title child
note is left alone. Notes are emitted as child items of
``zot_parent_id``."""
con = store._con() # noqa: SLF001
rows = con.execute(
"""SELECT n.title, n.content
FROM notes n
JOIN items i ON n.item_id = i.id
WHERE i.key = ?""",
(bib_item.key,),
).fetchall()
if not rows:
return 0
existing_titles = {
r[0]
for r in db.con.execute(
"SELECT title FROM itemNotes WHERE parentItemID = ?",
(zot_parent_id,),
).fetchall()
}
count = 0
for row in rows:
title = row["title"] or ""
if title in existing_titles:
continue
db.add_note(zot_parent_id, row["content"], title=title)
existing_titles.add(title)
count += 1
return count
- Step 5: Wire
_sync_notesinto both branches ofpush_to_zotero
In src/bib/sync.py:
(a) Add notes to the stats dict initializer (around line 258):
stats: dict[str, int] = {
"created": 0,
"skipped": 0,
"tags": 0,
"collections": 0,
"attachments": 0,
"notes": 0,
}
(b) In the update branch (after _sync_attachments call around line 301):
if store is not None:
stats["attachments"] += _sync_attachments(
db, store, item, existing_id, storage_dir,
)
stats["notes"] += _sync_notes(
db, store, item, existing_id,
)
(c) In the create branch (after _sync_attachments call around line 367):
if store is not None:
stats["attachments"] += _sync_attachments(
db, store, item, item_id, storage_dir,
)
stats["notes"] += _sync_notes(
db, store, item, item_id,
)
- Step 6: Run tests to verify they pass
Run: uv run pytest tests/bib/test_sync.py -v
Expected: all sync tests pass, including the two new TestSyncNotes tests. Run the full sync test file (not just the new class) to catch regressions in the dict initialization.
- Step 7: Commit
git add src/bib/sync.py tests/bib/test_sync.py
git commit -m "$(cat <<'EOF'
feat(bib/sync): push bib notes into Zotero itemNotes
Mirrors _sync_attachments. Idempotent dedup keyed on
(parentItemID, title) so re-syncing is a no-op. Wired into both
the create-item and update-existing-item branches of push_to_zotero.
Adds 'notes' counter to the stats dict.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 4: Flip cli/comments.py from attach_file to attach_note
Files:
-
Modify:
src/cli/comments.py:60-100(_build_bib_helpers— replace per-file glob with single-note attach) -
Modify:
tests/cli/test_comments.py:62-107(rename + rewrite the existing attach test) -
Step 1: Rewrite the existing attach test
Replace test_extract_attaches_only_sibling_mds_to_bib in tests/cli/test_comments.py (lines 62–107) with:
def test_extract_attaches_combined_md_as_note(tmp_path: Path, monkeypatch):
"""Extract writes combined.md and per-attachment sibling MDs to disk.
Only one Zotero-bound artifact: a single bib note titled 'Comment text'
whose content is the rendered-HTML of combined.md. No file attachments."""
from bib import connect
from bib.item import Source
bib_db = tmp_path / "bib.sqlite"
monkeypatch.setattr("conf.path", lambda _: bib_db)
cdir = _seed(tmp_path)
comment_id = cdir.name # CMS-2024-0001-0001
store = connect(str(bib_db))
item = Source(
title=f"Comment {comment_id}",
url=f"https://www.regulations.gov/comment/{comment_id}",
)
item_key = store.upsert(item)
store.close()
result = runner.invoke(app, ["extract", "--root", str(tmp_path), "--workers", "1"])
assert result.exit_code == 0, result.output
# Disk siblings still written (kept for non-Zotero corpus consumers)
assert (cdir / "combined.md").is_file()
assert (cdir / "attachment_1.pdf.md").is_file()
def md_attachments():
store = connect(str(bib_db))
rows = list(
store._con().execute(
"SELECT a.filename FROM attachments a "
"JOIN items i ON a.item_id=i.id "
"WHERE i.key=? AND a.filename LIKE '%.md'",
(item_key,),
)
)
store.close()
return rows
def notes():
store = connect(str(bib_db))
rows = list(
store._con().execute(
"SELECT n.title, n.content FROM notes n "
"JOIN items i ON n.item_id=i.id WHERE i.key=?",
(item_key,),
)
)
store.close()
return rows
# No file attachments — old behavior is gone.
assert md_attachments() == []
# Exactly one note, rendered HTML.
rows = notes()
assert len(rows) == 1
title, content = rows[0]
assert title == "Comment text"
assert "<h1>" in content or "<h2>" in content
assert "Long body content" in content # the seeded PDF text shows up
# Idempotent on re-run
runner.invoke(app, ["extract", "--root", str(tmp_path), "--workers", "1"])
assert len(notes()) == 1
assert md_attachments() == []
- Step 2: Run test to verify it fails
Run: uv run pytest tests/cli/test_comments.py::test_extract_attaches_combined_md_as_note -v
Expected: failure — current code path still calls attach_file, so md_attachments() returns 1 row and notes() returns 0 rows.
- Step 3: Update
_build_bib_helpersin cli/comments.py
Replace the attach_callback block in src/cli/comments.py (lines 60–100). The full new shape of the function (lines 38 onward):
if not use_bib:
return lambda _cid: "", None
import sqlite3
from conf import path
db = str(path("db.bib"))
con = sqlite3.connect(db, check_same_thread=False)
cache: dict[str, tuple[str, str]] = {}
for row in con.execute(
"SELECT key, url, abstract FROM items "
"WHERE url LIKE 'https://www.regulations.gov/comment/%'"
):
cid = (row[1] or "").rsplit("/", 1)[-1]
if cid:
cache[cid] = (row[0], row[2] or "")
def body_lookup(comment_id: str) -> str:
return cache.get(comment_id, ("", ""))[1]
if not attach:
return body_lookup, None
from bib import connect
from rex.comments.render import render_combined_md
store = connect()
# Items that already have a "Comment text" note — keeps re-runs idempotent.
NOTE_TITLE = "Comment text"
items_with_note: set[str] = {
row[0]
for row in store._con().execute(
"SELECT i.key FROM notes n "
"JOIN items i ON n.item_id = i.id "
"WHERE n.title = ?",
(NOTE_TITLE,),
)
}
def attach_callback(comment_id: str, comment_dir: Path) -> None:
info = cache.get(comment_id)
if not info:
return # comment not in bib
item_key = info[0]
if item_key in items_with_note:
return
combined = comment_dir / "combined.md"
if not combined.is_file():
return # extraction must have failed; nothing to attach
try:
html = render_combined_md(combined.read_text(encoding="utf-8"))
store.attach_note(item_key, html, title=NOTE_TITLE)
items_with_note.add(item_key)
except Exception as e: # noqa: BLE001
log.warning("attach_note failed for %s: %s", item_key, e)
return body_lookup, attach_callback
(Note: the old docstring lines 25–37 stay, but the part that mentions "registers combined.md as a bib attachment" needs an update — replace those four sentences with: "attach_callback(comment_id, comment_dir) -> None`` runs on the main thread and registers a single ``"Comment text"`` note on the bib item containing combined.md rendered to HTML. Skips items that already have one. Returns None when --no-bib or --no-attach.")
- Step 4: Run test to verify it passes
Run: uv run pytest tests/cli/test_comments.py -v
Expected: all comments tests pass, especially the rewritten note assertion.
- Step 5: Commit
git add src/cli/comments.py tests/cli/test_comments.py
git commit -m "$(cat <<'EOF'
feat(comments): attach combined.md as a Zotero note (HTML), not files
Replaces the per-attachment .md attach_file loop with a single
attach_note call per comment item: combined.md rendered to HTML via
rex.comments.render. Sibling .md files keep being written to disk
for non-Zotero corpus consumers.
Idempotency cache shifts from (item_key, filename) to a per-item
"Comment text" title check.
Migration of the 33,351 existing .md attachments lands in a
follow-up one-shot script.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 5: Migration script — Phase A (note backfill)
Files:
-
Create:
scripts/migrate_comment_md_to_notes.py -
Step 1: Write the script (Phase A only for now — destructive bits land in Task 6)
Create scripts/migrate_comment_md_to_notes.py:
#!/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")
NOTE_TITLE = "Comment text"
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()
- Step 2: Smoke-test against a tiny dummy fixture
Build a tmp comments dir with one combined.md, run Phase A against a tmp bib, verify a note exists.
uv run python -c "
import sqlite3, tempfile, pathlib, os
from bib import connect
from bib.item import Source
# tmp bib with one comment item
tmp = pathlib.Path(tempfile.mkdtemp())
db = tmp / 'bib.sqlite'
os.environ['BIB_DB'] = str(db)
store = connect(str(db))
key = store.upsert(Source(
title='Comment CMS-2024-0001-0001',
url='https://www.regulations.gov/comment/CMS-2024-0001-0001',
))
store.close()
# tmp comments dir with one combined.md
cdir = tmp / 'comments' / 'CMS-2024-0001' / 'CMS-2024-0001-0001'
cdir.mkdir(parents=True)
(cdir / 'combined.md').write_text(
'---\nstatus: ok\n---\n\n# Body\n\nText.\n'
)
# Run Phase A directly
from scripts.migrate_comment_md_to_notes import phase_a_backfill
stats = phase_a_backfill(tmp / 'comments')
print('STATS:', stats)
assert stats['attached'] == 1, stats
# Verify note exists
con = sqlite3.connect(str(db))
title, content = con.execute('SELECT title, content FROM notes').fetchone()
print('NOTE:', title)
assert title == 'Comment text'
assert '<h1>Body</h1>' in content
print('OK')
"
Expected: STATS: {'scanned': 1, 'attached': 1, ...} and NOTE: Comment text and OK. (Path import via scripts. may need a sys.path.insert; if the import fails, run with PYTHONPATH=. prefix.)
- Step 3: Commit
git add scripts/migrate_comment_md_to_notes.py
git commit -m "$(cat <<'EOF'
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>
EOF
)"
Task 6: Migration script — Phase C (destructive cleanup)
Files:
-
Modify:
scripts/migrate_comment_md_to_notes.py— replace the Phase C placeholder -
Step 1: Add Phase C implementation
In scripts/migrate_comment_md_to_notes.py, replace the placeholder block:
# Phase C lands in Task 6.
log.warning("--cleanup not yet implemented; landing in Task 6")
with:
log.info("Phase C: destructive cleanup of .md file attachments")
counts = _phase_c_preview()
log.info("Will delete: %s", counts)
if not _confirm("Proceed with destructive cleanup?"):
log.info("Aborted.")
return
stats_c = phase_c_cleanup()
log.info("Phase C complete: %s", stats_c)
And add these helpers above main():
def _confirm(msg: str) -> bool:
"""Prompt y/N. Defaults to N."""
reply = input(f"{msg} [y/N] ").strip().lower()
return reply == "y"
def _phase_c_preview() -> dict[str, int]:
from conf import path
bib_db = sqlite3.connect(str(path("db.bib")))
n_bib = bib_db.execute(
"SELECT COUNT(*) FROM attachments WHERE filename LIKE '%.md'"
).fetchone()[0]
bib_db.close()
zot_db = sqlite3.connect(str(path("db.zotero")))
n_zot = zot_db.execute(
"SELECT COUNT(*) FROM itemAttachments WHERE path LIKE 'storage:%.md'"
).fetchone()[0]
zot_db.close()
return {"bib_md_attachments": n_bib, "zotero_md_attachments": n_zot}
def phase_c_cleanup() -> dict[str, int]:
"""Delete every .md attachment row + storage dir on bib and Zotero."""
from conf import path
stats = {"bib_rows_deleted": 0, "bib_dirs_removed": 0,
"zot_rows_deleted": 0, "zot_dirs_removed": 0,
"zot_dirs_skipped_nonempty": 0}
# ── bib ──────────────────────────────────────────
bib_con = sqlite3.connect(str(path("db.bib")))
bib_rows = bib_con.execute(
"SELECT id, storage_path FROM attachments WHERE filename LIKE '%.md'"
).fetchall()
for _att_id, storage_path in bib_rows:
if storage_path:
d = Path(storage_path).parent
if d.is_dir():
shutil.rmtree(d, ignore_errors=True)
stats["bib_dirs_removed"] += 1
bib_con.execute("DELETE FROM attachments WHERE filename LIKE '%.md'")
stats["bib_rows_deleted"] = bib_con.total_changes
bib_con.commit()
bib_con.close()
# ── Zotero ───────────────────────────────────────
zot_db_path = str(path("db.zotero"))
zot_storage = Path(zot_db_path).parent / "storage"
zot_con = sqlite3.connect(zot_db_path)
zot_rows = zot_con.execute(
"SELECT i.itemID, i.key FROM items i "
"JOIN itemAttachments ia ON ia.itemID = i.itemID "
"WHERE ia.path LIKE 'storage:%.md'"
).fetchall()
item_ids_to_delete: list[int] = []
for item_id, item_key in zot_rows:
d = zot_storage / item_key
if d.is_dir():
non_md = [p for p in d.iterdir() if not p.name.endswith(".md")]
if non_md:
log.warning("skip storage dir %s (contains non-md: %s)",
d, [p.name for p in non_md])
stats["zot_dirs_skipped_nonempty"] += 1
# We still drop the rows — Zotero will show a missing
# attachment, easier to clean than a phantom row.
else:
shutil.rmtree(d, ignore_errors=True)
stats["zot_dirs_removed"] += 1
item_ids_to_delete.append(item_id)
if item_ids_to_delete:
placeholders = ",".join("?" * len(item_ids_to_delete))
zot_con.execute(
f"DELETE FROM itemAttachments WHERE itemID IN ({placeholders})",
item_ids_to_delete,
)
zot_con.execute(
f"DELETE FROM items WHERE itemID IN ({placeholders})",
item_ids_to_delete,
)
stats["zot_rows_deleted"] = len(item_ids_to_delete)
zot_con.commit()
zot_con.close()
return stats
- Step 2: Smoke-test the safety pre-flight only
Don't run against real data yet — just confirm the safety guard fires when expected. Comment out _sync_notes momentarily, run, restore:
# Save current sync.py
cp src/bib/sync.py /tmp/sync.py.bak
# Strip the helper temporarily
sed -i 's/_sync_notes/_disabled_sync_notes/g' src/bib/sync.py
uv run python scripts/migrate_comment_md_to_notes.py --cleanup 2>&1 | head -3
# Expected: "FATAL: src/bib/sync.py has no _sync_notes — apply Task 3 first."
# Restore
cp /tmp/sync.py.bak src/bib/sync.py
rm /tmp/sync.py.bak
Expected: the FATAL message appears; non-zero exit. Restore leaves git status clean.
- Step 3: Commit
git add scripts/migrate_comment_md_to_notes.py
git commit -m "$(cat <<'EOF'
chore(scripts): migration Phase C — destructive .md attachment cleanup
Gated by --cleanup flag and y/N confirmation. Walks bib.attachments
and Zotero itemAttachments + items, removes storage dirs that
contain only the .md file (non-empty dirs with foreign content are
logged and the dir is skipped, but the SQL rows still drop).
Safety pre-flight refuses to run unless src/bib/sync.py contains
_sync_notes — sanity check that re-syncing won't recreate the
attachments we're deleting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 7: Execute the migration against real data
Not a code change — operator-driven steps. Stop and confirm with the user before each phase.
- Step 1: Snapshot current counts
uv run python -c "
import sqlite3
from conf import path
b = sqlite3.connect(str(path('db.bib')))
print('bib .md:', b.execute(\"SELECT COUNT(*) FROM attachments WHERE filename LIKE '%.md'\").fetchone()[0])
print('bib notes:', b.execute(\"SELECT COUNT(*) FROM notes WHERE title='Comment text'\").fetchone()[0])
b.close()
z = sqlite3.connect(str(path('db.zotero')))
print('zot .md attachments:', z.execute(\"SELECT COUNT(*) FROM itemAttachments WHERE path LIKE 'storage:%.md'\").fetchone()[0])
print('zot Comment text notes:', z.execute(\"SELECT COUNT(*) FROM itemNotes WHERE title='Comment text'\").fetchone()[0])
z.close()
"
Expected starting state: bib .md ≈ 33,351; bib notes 0; zot .md attachments ≈ 33,351; zot Comment text notes 0.
- Step 2: Run Phase A
uv run python scripts/migrate_comment_md_to_notes.py
Expected: log lines every 1000 dirs; final stats line with attached ≈ 23,605.
Re-run the snapshot from Step 1. bib notes should now ≈ 23,605.
- Step 3: Push to Zotero (Phase B)
uv run stack bib sync
Expected: stats line includes notes: ~23605.
Re-run snapshot. zot Comment text notes should now ≈ 23,605. (Zot .md attachments still ≈ 33,351 at this point — Phase C handles those.)
- Step 4: Sanity check in Zotero UI before destructive cleanup
Open Zotero, find a CMS-2024 comment, confirm:
- The "Comment text" child note exists.
- Opening it in the right pane shows rendered HTML (headings, paragraphs, tables) — NOT raw markdown source.
If anything looks off (note empty, looks like raw markdown, formatting busted), STOP. Don't proceed to Phase C.
- Step 5: Run Phase C with confirmation
uv run python scripts/migrate_comment_md_to_notes.py --cleanup
Expected:
-
Phase A re-runs as no-op (
attached: 0,skipped_have_note: ~23605). -
"Will delete: {'bib_md_attachments': ~33351, 'zotero_md_attachments': ~33351}".
-
Confirmation prompt — review counts, type
y. -
Phase C completes with
bib_rows_deleted: ~33351,zot_rows_deleted: ~33351, etc. -
Step 6: Verify final state
Re-run the snapshot from Step 1. Expected:
- bib .md: 0
- bib notes: ~23,605
- zot .md attachments: 0
- zot Comment text notes: ~23,605
Spot-check Zotero UI: pick a few items, confirm child shows note (not file attachment).
Task 8: Remove the migration script
Files:
-
Delete:
scripts/migrate_comment_md_to_notes.py -
Step 1: Delete the script
git rm scripts/migrate_comment_md_to_notes.py
- Step 2: Commit
git commit -m "$(cat <<'EOF'
chore(scripts): remove one-shot comment-md→notes migration
Run successfully against the 23,605 comment items / 33,351 .md
file attachments. bib + Zotero now hold only the rendered-HTML
'Comment text' notes; old file attachments and storage dirs are
gone. Restoring this script for re-runs is one git revert away.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Self-Review Notes
After writing this plan I checked:
- Spec coverage: every section of the spec maps to a task — renderer (T2), write path (T4), sync path (T3), migration phases A/B/C (T5/T7/T6), siblings-stay-on-disk (T4 keeps the assertion), deps (T1), code touch list (covered across tasks).
- Type consistency:
NOTE_TITLE = "Comment text"is the same string incli/comments.py, the migration script, and the test assertions._sync_notessignature matches_sync_attachments. Stats keynotesconsistent across sync.py, tests, and migration steps. - Placeholders: none of the forbidden patterns. Every code step includes the actual code.
- Out-of-order safety: Tasks 1-6 are merge-safe in any order within their phase but the listed order is the strict dependency chain — T1 unlocks T2, T2 unlocks T4 and T5, T3 unlocks T7 step 3.