aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-29 18:45:11 -0500
committerCraig Jennings <c@cjennings.net>2026-05-29 18:45:11 -0500
commit506ab6e7f7b41097aaa832d168bd427788be3296 (patch)
tree930a079a148c5a0020b3d1901e681240d15a0103
parentbb28cfa331d3cba9ae9f265f7c448b5bd6b4fa6c (diff)
downloadrulesets-506ab6e7f7b41097aaa832d168bd427788be3296.tar.gz
rulesets-506ab6e7f7b41097aaa832d168bd427788be3296.zip
feat(scripts): add drill-to-anki.py template script (org-drill to Anki .apkg)
Generalizes the health-drill-to-anki.py converter into a template script under claude-templates/.ai/scripts/. Every project's startup rsync now picks it up at .ai/scripts/drill-to-anki.py. The converter walks an org-drill file. Top-level * Section headings become Anki tags. Each ** Card name :drill: entry becomes a card with the heading as Front and the body as Back (newlines converted to <br>). Parameterized from the original health-specific version: - Input file is a positional argument (was hardcoded health-drill.org). - Deck name defaults to the org file's #+TITLE if present, else the input basename in Title Case (was hardcoded "Health Drill"). - Output path defaults to ~/sync/org/drill/<input-basename>.apkg (was hardcoded health-drill.apkg in the same dir). - Deck and model IDs are derived from the deck name via SHA-256, so re-running against the same source produces stable IDs. Anki imports update existing cards in place rather than duplicating. Dependencies via PEP 723 inline script metadata. The shebang is #!/usr/bin/env -S uv run --script. First run resolves and caches genanki>=0.13. Subsequent runs are instant. There is no venv to maintain and no PEP 668 friction. Smoke tested against ~/projects/health/health-drill.org. The script wrote 43 cards into the Health Drill deck, matching the original script's output. Inbox source: 2026-05-29-1114-from-health-todo-b-org-drill-anki-export-updated.org. Craig confirmed all projects will likely have org-drill files, justifying template-tier promotion.
-rwxr-xr-x.ai/scripts/drill-to-anki.py214
-rwxr-xr-xclaude-templates/.ai/scripts/drill-to-anki.py214
2 files changed, 428 insertions, 0 deletions
diff --git a/.ai/scripts/drill-to-anki.py b/.ai/scripts/drill-to-anki.py
new file mode 100755
index 0000000..50e1afd
--- /dev/null
+++ b/.ai/scripts/drill-to-anki.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "genanki>=0.13",
+# ]
+# ///
+"""Convert an org-drill file into an Anki .apkg deck.
+
+Parses org-drill structure:
+ - Top-level "* Section" headings become tags on every card under them.
+ - Each "** Card name :drill:" entry becomes a card. Front = heading
+ text (sans :drill: tag). Back = entry body with newlines converted
+ to <br>.
+
+Deck name defaults to the org file's #+TITLE if present, otherwise the
+input basename. Deck and model IDs are derived from the deck name via
+stable hash so re-importing the same deck updates existing cards
+instead of duplicating them.
+
+Output defaults to ~/sync/org/drill/<input-basename>.apkg (matches
+Craig's project convention where org source lives in the project and
+symlinks into ~/sync/org/drill/, with the .apkg writing directly into
+~/sync/org/drill/ as a build artifact).
+
+Usage:
+ drill-to-anki.py <input.org>
+ drill-to-anki.py <input.org> --deck "My Deck Name"
+ drill-to-anki.py <input.org> --output /path/to/deck.apkg
+
+Requires genanki, which uv resolves automatically via the PEP 723
+script metadata above. No venv or system install needed.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import re
+import sys
+from pathlib import Path
+
+import genanki
+
+# 32-bit integer space genanki accepts. Start above the conventional
+# "user model" floor so collisions with hand-written decks stay
+# unlikely.
+ID_BASE = 1_500_000_000
+ID_RANGE = 500_000_000
+
+
+def stable_id(name: str, salt: str) -> int:
+ """Derive a deterministic 32-bit id from `name` and a `salt`.
+
+ Same (name, salt) pair always returns the same id, so re-running
+ against the same source produces a stable deck/model id pair and
+ Anki imports update existing cards in place rather than duplicating.
+ """
+ h = hashlib.sha256(f"{salt}:{name}".encode()).hexdigest()
+ return ID_BASE + (int(h[:8], 16) % ID_RANGE)
+
+
+def make_model(deck_name: str) -> genanki.Model:
+ return genanki.Model(
+ stable_id(deck_name, "model"),
+ f"{deck_name} (Craig)",
+ fields=[{"name": "Front"}, {"name": "Back"}],
+ templates=[
+ {
+ "name": "Card 1",
+ "qfmt": "{{Front}}",
+ "afmt": '{{FrontSide}}<hr id="answer">{{Back}}',
+ }
+ ],
+ css=(
+ ".card { font-family: sans-serif; font-size: 18px; "
+ "color: #222; background: #fafafa; line-height: 1.45; }\n"
+ "hr#answer { margin: 14px 0; }\n"
+ ),
+ )
+
+
+def section_to_tag(title: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
+
+
+def escape_html(s: str) -> str:
+ return (
+ s.replace("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ )
+
+
+def title_from_org(org_text: str) -> str | None:
+ """Return the #+TITLE: value if the org file declares one."""
+ for line in org_text.splitlines()[:20]:
+ m = re.match(r"^#\+TITLE:\s*(.+?)\s*$", line, re.IGNORECASE)
+ if m:
+ return m.group(1).strip()
+ return None
+
+
+def parse(org_text: str) -> list[tuple[str, str, str]]:
+ """Return [(front, back_html, tag), ...] for every :drill: card."""
+ cards: list[tuple[str, str, str]] = []
+ current_section: str | None = None
+
+ section_re = re.compile(r"^\*\s+(.+?)\s*$")
+ card_re = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$")
+
+ lines = org_text.splitlines()
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+
+ sec = section_re.match(line)
+ if sec:
+ current_section = sec.group(1).strip()
+ i += 1
+ continue
+
+ card = card_re.match(line)
+ if card:
+ front = card.group(1).strip()
+ body_lines: list[str] = []
+ i += 1
+ while i < len(lines):
+ nxt = lines[i]
+ if nxt.startswith("* ") or card_re.match(nxt):
+ break
+ body_lines.append(nxt)
+ i += 1
+ while body_lines and not body_lines[0].strip():
+ body_lines.pop(0)
+ while body_lines and not body_lines[-1].strip():
+ body_lines.pop()
+ back_html = "<br>".join(escape_html(ln) for ln in body_lines)
+ tag = section_to_tag(current_section) if current_section else "drill"
+ cards.append((front, back_html, tag))
+ continue
+
+ i += 1
+
+ return cards
+
+
+def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck:
+ deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name)
+ model = make_model(deck_name)
+ for front, back, tag in cards:
+ note = genanki.Note(
+ model=model,
+ fields=[front, back],
+ tags=[tag],
+ guid=genanki.guid_for(front),
+ )
+ deck.add_note(note)
+ return deck
+
+
+def default_deck_name(input_path: Path, org_text: str) -> str:
+ return title_from_org(org_text) or input_path.stem.replace("-", " ").title()
+
+
+def default_output_path(input_path: Path) -> Path:
+ drill_dir = Path.home() / "sync" / "org" / "drill"
+ return drill_dir / f"{input_path.stem}.apkg"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Convert an org-drill file into an Anki .apkg deck.",
+ )
+ parser.add_argument(
+ "input",
+ type=Path,
+ help="Path to the org-drill source file.",
+ )
+ parser.add_argument(
+ "--deck",
+ help="Deck name. Defaults to the org file's #+TITLE, or the "
+ "input basename if no title is set.",
+ )
+ parser.add_argument(
+ "--output",
+ type=Path,
+ help="Output .apkg path. Defaults to "
+ "~/sync/org/drill/<input-basename>.apkg.",
+ )
+ args = parser.parse_args()
+
+ input_path: Path = args.input.expanduser().resolve()
+ if not input_path.is_file():
+ print(f"error: {input_path} not found", file=sys.stderr)
+ return 1
+
+ org_text = input_path.read_text(encoding="utf-8")
+ deck_name = args.deck or default_deck_name(input_path, org_text)
+ output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve()
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ cards = parse(org_text)
+ if not cards:
+ print(f"error: no :drill: cards found in {input_path}", file=sys.stderr)
+ return 1
+
+ deck = build(cards, deck_name)
+ genanki.Package(deck).write_to_file(str(output_path))
+ print(f"wrote {output_path} ({len(cards)} cards, deck '{deck_name}')")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/claude-templates/.ai/scripts/drill-to-anki.py b/claude-templates/.ai/scripts/drill-to-anki.py
new file mode 100755
index 0000000..50e1afd
--- /dev/null
+++ b/claude-templates/.ai/scripts/drill-to-anki.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "genanki>=0.13",
+# ]
+# ///
+"""Convert an org-drill file into an Anki .apkg deck.
+
+Parses org-drill structure:
+ - Top-level "* Section" headings become tags on every card under them.
+ - Each "** Card name :drill:" entry becomes a card. Front = heading
+ text (sans :drill: tag). Back = entry body with newlines converted
+ to <br>.
+
+Deck name defaults to the org file's #+TITLE if present, otherwise the
+input basename. Deck and model IDs are derived from the deck name via
+stable hash so re-importing the same deck updates existing cards
+instead of duplicating them.
+
+Output defaults to ~/sync/org/drill/<input-basename>.apkg (matches
+Craig's project convention where org source lives in the project and
+symlinks into ~/sync/org/drill/, with the .apkg writing directly into
+~/sync/org/drill/ as a build artifact).
+
+Usage:
+ drill-to-anki.py <input.org>
+ drill-to-anki.py <input.org> --deck "My Deck Name"
+ drill-to-anki.py <input.org> --output /path/to/deck.apkg
+
+Requires genanki, which uv resolves automatically via the PEP 723
+script metadata above. No venv or system install needed.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import re
+import sys
+from pathlib import Path
+
+import genanki
+
+# 32-bit integer space genanki accepts. Start above the conventional
+# "user model" floor so collisions with hand-written decks stay
+# unlikely.
+ID_BASE = 1_500_000_000
+ID_RANGE = 500_000_000
+
+
+def stable_id(name: str, salt: str) -> int:
+ """Derive a deterministic 32-bit id from `name` and a `salt`.
+
+ Same (name, salt) pair always returns the same id, so re-running
+ against the same source produces a stable deck/model id pair and
+ Anki imports update existing cards in place rather than duplicating.
+ """
+ h = hashlib.sha256(f"{salt}:{name}".encode()).hexdigest()
+ return ID_BASE + (int(h[:8], 16) % ID_RANGE)
+
+
+def make_model(deck_name: str) -> genanki.Model:
+ return genanki.Model(
+ stable_id(deck_name, "model"),
+ f"{deck_name} (Craig)",
+ fields=[{"name": "Front"}, {"name": "Back"}],
+ templates=[
+ {
+ "name": "Card 1",
+ "qfmt": "{{Front}}",
+ "afmt": '{{FrontSide}}<hr id="answer">{{Back}}',
+ }
+ ],
+ css=(
+ ".card { font-family: sans-serif; font-size: 18px; "
+ "color: #222; background: #fafafa; line-height: 1.45; }\n"
+ "hr#answer { margin: 14px 0; }\n"
+ ),
+ )
+
+
+def section_to_tag(title: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
+
+
+def escape_html(s: str) -> str:
+ return (
+ s.replace("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ )
+
+
+def title_from_org(org_text: str) -> str | None:
+ """Return the #+TITLE: value if the org file declares one."""
+ for line in org_text.splitlines()[:20]:
+ m = re.match(r"^#\+TITLE:\s*(.+?)\s*$", line, re.IGNORECASE)
+ if m:
+ return m.group(1).strip()
+ return None
+
+
+def parse(org_text: str) -> list[tuple[str, str, str]]:
+ """Return [(front, back_html, tag), ...] for every :drill: card."""
+ cards: list[tuple[str, str, str]] = []
+ current_section: str | None = None
+
+ section_re = re.compile(r"^\*\s+(.+?)\s*$")
+ card_re = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$")
+
+ lines = org_text.splitlines()
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+
+ sec = section_re.match(line)
+ if sec:
+ current_section = sec.group(1).strip()
+ i += 1
+ continue
+
+ card = card_re.match(line)
+ if card:
+ front = card.group(1).strip()
+ body_lines: list[str] = []
+ i += 1
+ while i < len(lines):
+ nxt = lines[i]
+ if nxt.startswith("* ") or card_re.match(nxt):
+ break
+ body_lines.append(nxt)
+ i += 1
+ while body_lines and not body_lines[0].strip():
+ body_lines.pop(0)
+ while body_lines and not body_lines[-1].strip():
+ body_lines.pop()
+ back_html = "<br>".join(escape_html(ln) for ln in body_lines)
+ tag = section_to_tag(current_section) if current_section else "drill"
+ cards.append((front, back_html, tag))
+ continue
+
+ i += 1
+
+ return cards
+
+
+def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck:
+ deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name)
+ model = make_model(deck_name)
+ for front, back, tag in cards:
+ note = genanki.Note(
+ model=model,
+ fields=[front, back],
+ tags=[tag],
+ guid=genanki.guid_for(front),
+ )
+ deck.add_note(note)
+ return deck
+
+
+def default_deck_name(input_path: Path, org_text: str) -> str:
+ return title_from_org(org_text) or input_path.stem.replace("-", " ").title()
+
+
+def default_output_path(input_path: Path) -> Path:
+ drill_dir = Path.home() / "sync" / "org" / "drill"
+ return drill_dir / f"{input_path.stem}.apkg"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Convert an org-drill file into an Anki .apkg deck.",
+ )
+ parser.add_argument(
+ "input",
+ type=Path,
+ help="Path to the org-drill source file.",
+ )
+ parser.add_argument(
+ "--deck",
+ help="Deck name. Defaults to the org file's #+TITLE, or the "
+ "input basename if no title is set.",
+ )
+ parser.add_argument(
+ "--output",
+ type=Path,
+ help="Output .apkg path. Defaults to "
+ "~/sync/org/drill/<input-basename>.apkg.",
+ )
+ args = parser.parse_args()
+
+ input_path: Path = args.input.expanduser().resolve()
+ if not input_path.is_file():
+ print(f"error: {input_path} not found", file=sys.stderr)
+ return 1
+
+ org_text = input_path.read_text(encoding="utf-8")
+ deck_name = args.deck or default_deck_name(input_path, org_text)
+ output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve()
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ cards = parse(org_text)
+ if not cards:
+ print(f"error: no :drill: cards found in {input_path}", file=sys.stderr)
+ return 1
+
+ deck = build(cards, deck_name)
+ genanki.Package(deck).write_to_file(str(output_path))
+ print(f"wrote {output_path} ({len(cards)} cards, deck '{deck_name}')")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())