diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-29 18:45:11 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-29 18:45:11 -0500 |
| commit | 506ab6e7f7b41097aaa832d168bd427788be3296 (patch) | |
| tree | 930a079a148c5a0020b3d1901e681240d15a0103 | |
| parent | bb28cfa331d3cba9ae9f265f7c448b5bd6b4fa6c (diff) | |
| download | rulesets-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.py | 214 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/drill-to-anki.py | 214 |
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("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +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("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +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()) |
