diff options
Diffstat (limited to '.ai/scripts/flashcard-to-anki.py')
| -rwxr-xr-x | .ai/scripts/flashcard-to-anki.py | 105 |
1 files changed, 84 insertions, 21 deletions
diff --git a/.ai/scripts/flashcard-to-anki.py b/.ai/scripts/flashcard-to-anki.py index ca4c70b..e369fd8 100755 --- a/.ai/scripts/flashcard-to-anki.py +++ b/.ai/scripts/flashcard-to-anki.py @@ -10,8 +10,15 @@ 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 + text (sans the tag block). Back = entry body with newlines converted to <br>. + - 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 <tag> 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 @@ -27,6 +34,8 @@ 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. @@ -47,6 +56,15 @@ import genanki 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`. @@ -120,33 +138,40 @@ def strip_org_metadata(body_lines: list[str]) -> list[str]: 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 +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. - section_re = re.compile(r"^\*\s+(.+?)\s*$") - card_re = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") + 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) + 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() + 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 nxt.startswith("* ") or card_re.match(nxt): + if HEADING_RE.match(nxt): break body_lines.append(nxt) i += 1 @@ -156,8 +181,17 @@ def parse(org_text: str) -> list[tuple[str, str, str]]: 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)) + + 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 @@ -165,15 +199,28 @@ def parse(org_text: str) -> list[tuple[str, str, str]]: return cards -def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck: +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, tag in cards: + for front, back, tags in cards: note = genanki.Note( model=model, fields=[front, back], - tags=[tag], - guid=genanki.guid_for(front), + tags=tags, + guid=card_guid(front, guid_salt), ) deck.add_note(note) return deck @@ -219,6 +266,16 @@ def main() -> int: 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. --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() @@ -231,12 +288,18 @@ def main() -> int: 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) + cards = parse(org_text, tag_filter=args.tag_filter) if not cards: - print(f"error: no :drill: cards found in {input_path}", file=sys.stderr) + 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) + 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 |
