aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/drill-to-anki.py
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts/drill-to-anki.py')
-rwxr-xr-x.ai/scripts/drill-to-anki.py232
1 files changed, 0 insertions, 232 deletions
diff --git a/.ai/scripts/drill-to-anki.py b/.ai/scripts/drill-to-anki.py
deleted file mode 100755
index 9fe954e..0000000
--- a/.ai/scripts/drill-to-anki.py
+++ /dev/null
@@ -1,232 +0,0 @@
-#!/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 <br>.
-
-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/<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:
- drill-to-anki.py <input.org>
- drill-to-anki.py <input.org> --deck "My Deck Name"
- drill-to-anki.py <input.org> --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}}<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("&", "&amp;")
- .replace("<", "&lt;")
- .replace(">", "&gt;")
- )
-
-
-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 = "<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))
- 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/<input-basename>.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())