#!/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 org tag block). Back = entry body with newlines converted to
. A card heading may carry more than one org tag (e.g. "** Question :fundamental:drill:"). Any heading whose trailing tag block includes `drill` is a card; the other org tags ride along as Anki tags next to the section tag. Pass --tag-filter to emit only the cards carrying that org tag (e.g. a curated "fundamentals" subset). 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. Note GUIDs default to a hash of the card front, so re-running against the same source preserves SRS state. A derived subset deck (one built with --tag-filter) should pass --guid-salt so its notes get a distinct GUID space and Anki treats it as a separate deck rather than merging its cards into a full deck that shares the same fronts. 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 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 # A level-2 heading carrying a trailing org tag block. Group 1 is the # front text, group 2 the colon-delimited tag block (e.g. ":fundamental:drill:"). CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$") # Any level-1 or level-2 heading — used to bound a card's body. HEADING_RE = re.compile(r"^\*{1,2}\s") SECTION_RE = re.compile(r"^\*\s+(.+?)\s*$") 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`. Additional org tags become Anki tags alongside the section tag. When `tag_filter` is set, only cards carrying that org tag are returned. """ 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 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: guid = ( genanki.guid_for(guid_salt, front) if guid_salt else genanki.guid_for(front) ) note = genanki.Note( model=model, fields=[front, back], tags=tags, guid=guid, ) 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.", ) parser.add_argument( "--tag-filter", help="Emit only cards carrying this org tag (e.g. 'fundamental').", ) parser.add_argument( "--guid-salt", help="Salt note GUIDs with this string so a derived subset deck " "gets its own GUID space and Anki keeps it separate from a " "full deck sharing the same card fronts.", ) 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, 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())