#!/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
.
Deck name defaults to the input basename, case preserved. 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/phone/anki/.apkg. The .apkg is
a mobile-Anki artifact the phone picks up from its sync dir, so it lands
there rather than next to the org source.
Usage:
flashcard-to-anki.py
flashcard-to-anki.py --deck "My Deck Name"
flashcard-to-anki.py --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}}
{{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 strip_org_metadata(body_lines: list[str]) -> list[str]:
"""Drop :PROPERTIES: drawers, planning lines, and created-date lines.
Org-drill needs these in the source file (SRS state lives in the
PROPERTIES drawer; SCHEDULED carries the next-review date), but they
are noise on the back of an Anki card. A created/added date never
belongs on a card, so a stray "Created:" or ":CREATED:" body line is
dropped too.
"""
cleaned: list[str] = []
in_drawer = False
planning_re = re.compile(r"^\s*(SCHEDULED|DEADLINE|CLOSED):\s")
created_re = re.compile(r"^\s*:?created:?\s", re.IGNORECASE)
drawer_start_re = re.compile(r"^\s*:PROPERTIES:\s*$")
drawer_end_re = re.compile(r"^\s*:END:\s*$")
for line in body_lines:
if in_drawer:
if drawer_end_re.match(line):
in_drawer = False
continue
if drawer_start_re.match(line):
in_drawer = True
continue
if planning_re.match(line) or created_re.match(line):
continue
cleaned.append(line)
return cleaned
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
body_lines = strip_org_metadata(body_lines)
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 = "
".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) -> str:
return input_path.stem
def default_output_path(input_path: Path) -> Path:
anki_dir = Path.home() / "sync" / "phone" / "anki"
return anki_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 input basename.",
)
parser.add_argument(
"--output",
type=Path,
help="Output .apkg path. Defaults to "
"~/sync/phone/anki/.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)
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())