#!/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 the tag block). Back = entry body with newlines converted to
. - A card may carry a second org tag ("** Card :fundamental:drill:"). Any heading whose tag block includes `drill` is a card; the other tags ride along as Anki tags next to the section tag, so a curated subset stays grep-able in the source. --tag-filter emits only the cards carrying that tag, and a subset deck built that way should pass --guid-salt so its notes get their own GUID space (Anki dedupes on GUID, so without it the subset imports empty against the full deck). Deck name defaults to the org #+TITLE: (so the phone deck reads as the curated title), falling back to the input basename when the source has no #+TITLE. 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 flashcard-to-anki.py --tag-filter fundamental \ --deck "DeepSat Fundamentals" --guid-salt fundamentals 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 # A card is any level-2 heading whose trailing org tag block includes `drill`. # Group 1 is the front text, group 2 the colon-delimited tag block (e.g. # ":fundamental:drill:") — so a curated subset can carry a second org tag # (:fundamental:) and stay grep-able in the source without dropping from the # full deck. HEADING_RE bounds a card's body at the next L1/L2 heading. CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$") HEADING_RE = re.compile(r"^\*{1,2}\s") SECTION_RE = re.compile(r"^\*\s+(.+?)\s*$") 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, tag_filter: str | None = None ) -> list[tuple[str, str, list[str]]]: """Return [(front, back_html, anki_tags), ...] for every :drill: card. A card is any level-2 heading whose trailing org tag block includes `drill`. Non-drill org tags on the heading (e.g. :fundamental:) ride along as Anki tags next to the section tag, so a curated subset stays grep-able in the source. When `tag_filter` is set, only cards carrying that org tag are returned (the subset-deck path). """ cards: list[tuple[str, str, list[str]]] = [] current_section: str | None = None 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 m = CARD_RE.match(line) tags = [t for t in m.group(2).split(":") if t] if m else [] if m and "drill" in tags: front = m.group(1).strip() body_lines: list[str] = [] i += 1 while i < len(lines): nxt = lines[i] if HEADING_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) org_tags = [t for t in tags if t != "drill"] if tag_filter and tag_filter not in org_tags: continue anki_tags: list[str] = [] if current_section: anki_tags.append(section_to_tag(current_section)) anki_tags.extend(org_tags) if not anki_tags: anki_tags = ["drill"] cards.append((front, back_html, anki_tags)) continue i += 1 return cards def card_guid(front: str, guid_salt: str | None) -> str: """GUID for a card's front. A salt gives a derived subset deck its own GUID space so its notes don't collide with the full deck's (Anki dedupes on GUID, which would otherwise import the subset empty). No salt is the original behavior, so an unsalted deck's GUIDs and SRS state are untouched. """ return genanki.guid_for(guid_salt, front) if guid_salt else genanki.guid_for(front) def build( cards: list[tuple[str, str, list[str]]], deck_name: str, guid_salt: str | None = None, ) -> genanki.Deck: deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name) model = make_model(deck_name) for front, back, tags in cards: note = genanki.Note( model=model, fields=[front, back], tags=tags, guid=card_guid(front, guid_salt), ) deck.add_note(note) return deck def default_deck_name(input_path: Path, org_text: str) -> str: """Deck name defaults to the org #+TITLE:, falling back to the basename. The #+TITLE drives both the org-drill display in Emacs and the Anki deck name on the phone, so the consumed deck reads as the curated title ("Refutations") rather than the filename slug ("refutation-drill"). Falls back to the input basename (case preserved) when the source has no non-empty #+TITLE line. """ for line in org_text.splitlines(): m = re.match(r"^#\+TITLE:\s*(.*\S)\s*$", line, re.IGNORECASE) if m: return m.group(1).strip() 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 org #+TITLE, or the input basename.", ) parser.add_argument( "--output", type=Path, help="Output .apkg path. Defaults to " "~/sync/phone/anki/.apkg.", ) parser.add_argument( "--tag-filter", help="Emit only cards carrying this org tag (e.g. --tag-filter " "fundamental for a curated subset deck).", ) parser.add_argument( "--guid-salt", help="Salt note GUIDs so a subset deck gets its own GUID space and " "imports non-empty without disturbing the full deck's SRS state.", ) 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, tag_filter=args.tag_filter) if not cards: if args.tag_filter: print( f"error: no :drill: cards tagged :{args.tag_filter}: in {input_path}", file=sys.stderr, ) else: print(f"error: no :drill: cards found in {input_path}", file=sys.stderr) return 1 deck = build(cards, deck_name, guid_salt=args.guid_salt) 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())