diff options
Diffstat (limited to 'docs/design/2026-06-17-flashcard-multitag-to-anki.py')
| -rwxr-xr-x | docs/design/2026-06-17-flashcard-multitag-to-anki.py | 294 |
1 files changed, 294 insertions, 0 deletions
diff --git a/docs/design/2026-06-17-flashcard-multitag-to-anki.py b/docs/design/2026-06-17-flashcard-multitag-to-anki.py new file mode 100755 index 0000000..3764acf --- /dev/null +++ b/docs/design/2026-06-17-flashcard-multitag-to-anki.py @@ -0,0 +1,294 @@ +#!/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 <br>. + +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 <tag> 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/<input-basename>.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 <input.org> + flashcard-to-anki.py <input.org> --deck "My Deck Name" + flashcard-to-anki.py <input.org> --output /path/to/deck.apkg + flashcard-to-anki.py <input.org> --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}}<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 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 = "<br>".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/<input-basename>.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()) |
