aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/flashcard-to-anki.py
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts/flashcard-to-anki.py')
-rwxr-xr-x.ai/scripts/flashcard-to-anki.py105
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