diff options
Diffstat (limited to 'docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py')
| -rwxr-xr-x | docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py | 246 |
1 files changed, 0 insertions, 246 deletions
diff --git a/docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py b/docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py deleted file mode 100755 index ca4c70b..0000000 --- a/docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py +++ /dev/null @@ -1,246 +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 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/<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 - -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 - - -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, 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/<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, 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) - 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()) |
