From a14e43b566946ba45abf58f75eb0a445b5b67ef3 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 19 Jul 2026 18:40:22 -0500 Subject: feat(flashcard): support multi-tag drill headings + --tag-filter / --guid-salt Craig wanted a curated "DeepSat Fundamentals" deck: the ~100 most fundamental cards out of the 465-card deepsat.org deck, marked with a second org tag (:fundamental:) so they stay grep-able in the source. Both flashcard-to-anki.py and flashcard-stats.py keyed a card on a heading ending in exactly :drill:, so adding any second tag turned the heading into :fundamental:drill:, which that anchor didn't match. The 100 tagged cards would have silently dropped from the full-deck apkg and been undercounted by stats. That's the passing-gate-skips-your-file failure. CARD_RE now matches a trailing org tag block, and a heading is a card when drill is among its tags. The other tags ride along as Anki tags next to the section tag. Card bodies are bounded by any L1/L2 heading (HEADING_RE) rather than only a section or a drill heading. flashcard-to-anki.py gains --tag-filter (emit only cards carrying that org tag, the subset deck) and --guid-salt (a distinct GUID space so the subset imports non-empty instead of colliding with the full deck on GUID and importing empty). No salt is unchanged, so the existing deck's GUIDs and SRS state are untouched. flashcard-stats.py gets the same CARD_RE/HEADING_RE broadening plus a drill-membership guard. This is the rulesets-canonical reconcile of a stopgap the work project ran locally. I re-derived it against the current canonical rather than copying the preserved files, which predate the #+TITLE deck-name fix (060a938) and would have reverted it. parse()'s third element is now the Anki-tag list rather than a single tag string. Only build() and the tests consumed it. The existing parse tests move to the list contract, and new tests cover multi-tag parsing, --tag-filter, the guid salt, and the stats count. A bats case guards that a :fundamental:drill: card still counts. The full deck's real 465/100 check needs the work deck and isn't runnable here. Verified end to end instead on a synthetic multi-tag deck (2 cards, --tag-filter fundamental returns 1) through the real genanki path. --- .ai/scripts/flashcard-stats.py | 12 ++- .ai/scripts/flashcard-to-anki.py | 105 ++++++++++++++++----- .ai/scripts/tests/flashcard-sync.bats | 25 +++++ .ai/scripts/tests/test_apkg_to_orgdrill.py | 6 +- .ai/scripts/tests/test_flashcard_stats.py | 25 +++++ .ai/scripts/tests/test_flashcard_to_anki.py | 61 +++++++++++- claude-templates/.ai/scripts/flashcard-stats.py | 12 ++- claude-templates/.ai/scripts/flashcard-to-anki.py | 105 ++++++++++++++++----- .../.ai/scripts/tests/flashcard-sync.bats | 25 +++++ .../.ai/scripts/tests/test_apkg_to_orgdrill.py | 6 +- .../.ai/scripts/tests/test_flashcard_stats.py | 25 +++++ .../.ai/scripts/tests/test_flashcard_to_anki.py | 61 +++++++++++- 12 files changed, 408 insertions(+), 60 deletions(-) diff --git a/.ai/scripts/flashcard-stats.py b/.ai/scripts/flashcard-stats.py index 1fa5afb..cb580ac 100755 --- a/.ai/scripts/flashcard-stats.py +++ b/.ai/scripts/flashcard-stats.py @@ -35,7 +35,12 @@ import re import sys from pathlib import Path -CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") +# A card is a level-2 heading whose trailing org tag block includes `drill`. +# Group 1 is the front, group 2 the tag block — so a curated card multi-tagged +# :fundamental:drill: still counts (it would silently drop under a :drill:$ +# anchor, undercounting the deck). HEADING_RE bounds a card's body. +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$") +HEADING_RE = re.compile(r"^\*{1,2}\s") ANSWER_RE = re.compile(r"^\*\*\*\s+Answer\b") PROP_START_RE = re.compile(r"^\s*:PROPERTIES:\s*$") PROP_END_RE = re.compile(r"^\s*:END:\s*$") @@ -177,7 +182,8 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]: n = len(lines) while i < n: m = CARD_RE.match(lines[i]) - if not m: + tags = [t for t in m.group(2).split(":") if t] if m else [] + if not (m and "drill" in tags): i += 1 continue heading = m.group(1).strip() @@ -188,7 +194,7 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]: body_lines: list[str] = [] while i < n: line = lines[i] - if line.startswith("* ") or CARD_RE.match(line): + if HEADING_RE.match(line): break if PROP_START_RE.match(line): prop_count += 1 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
. + - 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 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 flashcard-to-anki.py --deck "My Deck Name" flashcard-to-anki.py --output /path/to/deck.apkg + flashcard-to-anki.py --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 = "
".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/.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 diff --git a/.ai/scripts/tests/flashcard-sync.bats b/.ai/scripts/tests/flashcard-sync.bats index 608a280..e6ffc21 100644 --- a/.ai/scripts/tests/flashcard-sync.bats +++ b/.ai/scripts/tests/flashcard-sync.bats @@ -6,6 +6,7 @@ setup() { SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" SYNC="$SCRIPT_DIR/flashcard-sync" + STATS="$SCRIPT_DIR/flashcard-stats.py" TMP="$(mktemp -d)" } @@ -36,3 +37,27 @@ EOF [ "$status" -eq 1 ] [ ! -f "$HOME/sync/phone/anki/dirty.apkg" ] } + +@test "flashcard-stats: a multi-tagged :fundamental:drill: card still counts" { + # Regression guard: a curated card carrying a second org tag must not drop + # from the count. A :drill:$ anchor would have counted only one card here. + cat > "$TMP/multitag.org" <<'EOF' +#+TITLE: Multitag Test + +* Orbital Regimes +** What is LEO? :fundamental:drill: +:PROPERTIES: +:ID: c1 +:END: +Low Earth Orbit is the region below about 2000 kilometers. +** What is GEO? :drill: +:PROPERTIES: +:ID: c2 +:END: +Geostationary orbit sits at roughly 35786 kilometers of altitude. +EOF + run python3 "$STATS" "$TMP/multitag.org" + [ "$status" -eq 0 ] + [[ "$output" == *"Cards: 2"* ]] + [[ "$output" == *clean* ]] +} diff --git a/.ai/scripts/tests/test_apkg_to_orgdrill.py b/.ai/scripts/tests/test_apkg_to_orgdrill.py index adffef0..6a95ea4 100644 --- a/.ai/scripts/tests/test_apkg_to_orgdrill.py +++ b/.ai/scripts/tests/test_apkg_to_orgdrill.py @@ -264,7 +264,7 @@ def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path): "** Capital of France? :drill:\n" "Paris.\n" ) - tuples = forward.parse(original) # [(front, back_html, tag), ...] + tuples = forward.parse(original) # [(front, back_html, anki_tags), ...] assert len(tuples) == 2 apkg = tmp_path / "rt.apkg" @@ -272,7 +272,9 @@ def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path): apkg, decks={20: "RT Deck"}, models={9: ["Front", "Back"]}, - notes=[(100 + i, 9, [f, b], t) for i, (f, b, t) in enumerate(tuples)], + # anki_tags is a list; the apkg tags field is space-joined. + notes=[(100 + i, 9, [f, b], " ".join(tags)) + for i, (f, b, tags) in enumerate(tuples)], cards=[(100 + i, 20) for i in range(len(tuples))], ) diff --git a/.ai/scripts/tests/test_flashcard_stats.py b/.ai/scripts/tests/test_flashcard_stats.py index 606f7c1..46deccc 100644 --- a/.ai/scripts/tests/test_flashcard_stats.py +++ b/.ai/scripts/tests/test_flashcard_stats.py @@ -217,6 +217,31 @@ def test_parse_cards_captures_body_without_drawer_planning_or_answer_header(stat assert c["body"] == "the real answer" +def test_parse_cards_counts_a_multitag_heading_as_a_card(stats): + """A card multi-tagged :fundamental:drill: still counts; the front is clean.""" + text = "* Sec\n** Q multi? :fundamental:drill:\nthe answer\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert len(cards) == 1 + assert cards[0]["heading"] == "Q multi?" + assert cards[0]["body"] == "the answer" + + +def test_parse_cards_ignores_a_tagged_heading_without_drill(stats): + """A tagged heading missing :drill: is not a drill card.""" + text = "* Sec\n** Just a note :note:\nbody\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert cards == [] + + +def test_parse_cards_body_stops_at_next_multitag_card(stats): + """The body scan ends at the next L2 card even when it is multi-tagged.""" + text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert len(cards) == 2 + assert cards[0]["body"] == "body1" + assert cards[1]["body"] == "body2" + + def test_find_duplicate_fronts_matches_normalized_headings(stats): cards = [ {"heading": "What is LEO?"}, diff --git a/.ai/scripts/tests/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py index 87008a8..fa38b64 100644 --- a/.ai/scripts/tests/test_flashcard_to_anki.py +++ b/.ai/scripts/tests/test_flashcard_to_anki.py @@ -158,17 +158,18 @@ Geostationary Earth Orbit. def test_parse_returns_front_back_tag_per_card(drill): cards = drill.parse(SECTIONED) assert len(cards) == 2 - assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes") + # The section becomes the sole Anki tag (as a one-element list). + assert cards[0] == ("What is LEO?", "Low Earth Orbit.", ["orbital-regimes"]) assert cards[1][0] == "What is GEO?" def test_parse_card_without_a_section_gets_the_drill_tag(drill): - assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")] + assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", ["drill"])] def test_parse_strips_properties_drawer_from_back(drill): text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n" - assert drill.parse(text) == [("Q?", "The answer.", "drill")] + assert drill.parse(text) == [("Q?", "The answer.", ["drill"])] def test_parse_trims_leading_and_trailing_blank_body_lines(drill): @@ -178,7 +179,59 @@ def test_parse_trims_leading_and_trailing_blank_body_lines(drill): def test_parse_card_with_only_a_drawer_has_empty_back(drill): text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n" - assert drill.parse(text) == [("Q?", "", "drill")] + assert drill.parse(text) == [("Q?", "", ["drill"])] + + +# --- multi-tag headings, --tag-filter, --guid-salt ------------------------- + +MULTITAG = """* Fundamentals +** What is LEO? :fundamental:drill: +Low Earth Orbit. +** What is GEO? :drill: +Geostationary Earth Orbit. +""" + + +def test_parse_multitag_heading_is_a_card_when_drill_is_present(drill): + """A heading with a second org tag still parses when drill is among them.""" + cards = drill.parse(MULTITAG) + assert len(cards) == 2 + assert cards[0][0] == "What is LEO?" + + +def test_parse_multitag_tags_ride_along_next_to_the_section_tag(drill): + """Non-drill org tags become Anki tags alongside the section tag.""" + cards = drill.parse(MULTITAG) + assert cards[0][2] == ["fundamentals", "fundamental"] # section slug + org tag + assert cards[1][2] == ["fundamentals"] # drill-only -> section only + + +def test_parse_heading_without_drill_tag_is_not_a_card(drill): + """A tagged heading missing :drill: is not a card (e.g. :note:).""" + assert drill.parse("* S\n** Just a note :note:\nbody\n") == [] + + +def test_parse_tag_filter_returns_only_cards_with_that_org_tag(drill): + """--tag-filter narrows to cards carrying the given org tag.""" + cards = drill.parse(MULTITAG, tag_filter="fundamental") + assert len(cards) == 1 + assert cards[0][0] == "What is LEO?" + + +def test_parse_body_bounded_by_any_l1_or_l2_heading(drill): + """A card body stops at the next L1/L2 heading, multi-tagged or not.""" + text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n" + cards = drill.parse(text) + assert cards[0][1] == "body1" + assert cards[1][1] == "body2" + + +def test_card_guid_salt_changes_the_guid(drill, monkeypatch): + """--guid-salt gives a subset deck its own GUID space; no salt is unchanged.""" + monkeypatch.setattr(drill.genanki, "guid_for", lambda *a: ":".join(a), raising=False) + assert drill.card_guid("front", None) == "front" + assert drill.card_guid("front", "fundamentals") == "fundamentals:front" + assert drill.card_guid("front", None) != drill.card_guid("front", "fundamentals") def test_parse_joins_multiline_body_with_br(drill): diff --git a/claude-templates/.ai/scripts/flashcard-stats.py b/claude-templates/.ai/scripts/flashcard-stats.py index 1fa5afb..cb580ac 100755 --- a/claude-templates/.ai/scripts/flashcard-stats.py +++ b/claude-templates/.ai/scripts/flashcard-stats.py @@ -35,7 +35,12 @@ import re import sys from pathlib import Path -CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$") +# A card is a level-2 heading whose trailing org tag block includes `drill`. +# Group 1 is the front, group 2 the tag block — so a curated card multi-tagged +# :fundamental:drill: still counts (it would silently drop under a :drill:$ +# anchor, undercounting the deck). HEADING_RE bounds a card's body. +CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$") +HEADING_RE = re.compile(r"^\*{1,2}\s") ANSWER_RE = re.compile(r"^\*\*\*\s+Answer\b") PROP_START_RE = re.compile(r"^\s*:PROPERTIES:\s*$") PROP_END_RE = re.compile(r"^\s*:END:\s*$") @@ -177,7 +182,8 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]: n = len(lines) while i < n: m = CARD_RE.match(lines[i]) - if not m: + tags = [t for t in m.group(2).split(":") if t] if m else [] + if not (m and "drill" in tags): i += 1 continue heading = m.group(1).strip() @@ -188,7 +194,7 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]: body_lines: list[str] = [] while i < n: line = lines[i] - if line.startswith("* ") or CARD_RE.match(line): + if HEADING_RE.match(line): break if PROP_START_RE.match(line): prop_count += 1 diff --git a/claude-templates/.ai/scripts/flashcard-to-anki.py b/claude-templates/.ai/scripts/flashcard-to-anki.py index ca4c70b..e369fd8 100755 --- a/claude-templates/.ai/scripts/flashcard-to-anki.py +++ b/claude-templates/.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
. + - 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 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 flashcard-to-anki.py --deck "My Deck Name" flashcard-to-anki.py --output /path/to/deck.apkg + flashcard-to-anki.py --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 = "
".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/.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 diff --git a/claude-templates/.ai/scripts/tests/flashcard-sync.bats b/claude-templates/.ai/scripts/tests/flashcard-sync.bats index 608a280..e6ffc21 100644 --- a/claude-templates/.ai/scripts/tests/flashcard-sync.bats +++ b/claude-templates/.ai/scripts/tests/flashcard-sync.bats @@ -6,6 +6,7 @@ setup() { SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" SYNC="$SCRIPT_DIR/flashcard-sync" + STATS="$SCRIPT_DIR/flashcard-stats.py" TMP="$(mktemp -d)" } @@ -36,3 +37,27 @@ EOF [ "$status" -eq 1 ] [ ! -f "$HOME/sync/phone/anki/dirty.apkg" ] } + +@test "flashcard-stats: a multi-tagged :fundamental:drill: card still counts" { + # Regression guard: a curated card carrying a second org tag must not drop + # from the count. A :drill:$ anchor would have counted only one card here. + cat > "$TMP/multitag.org" <<'EOF' +#+TITLE: Multitag Test + +* Orbital Regimes +** What is LEO? :fundamental:drill: +:PROPERTIES: +:ID: c1 +:END: +Low Earth Orbit is the region below about 2000 kilometers. +** What is GEO? :drill: +:PROPERTIES: +:ID: c2 +:END: +Geostationary orbit sits at roughly 35786 kilometers of altitude. +EOF + run python3 "$STATS" "$TMP/multitag.org" + [ "$status" -eq 0 ] + [[ "$output" == *"Cards: 2"* ]] + [[ "$output" == *clean* ]] +} diff --git a/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py b/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py index adffef0..6a95ea4 100644 --- a/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py +++ b/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py @@ -264,7 +264,7 @@ def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path): "** Capital of France? :drill:\n" "Paris.\n" ) - tuples = forward.parse(original) # [(front, back_html, tag), ...] + tuples = forward.parse(original) # [(front, back_html, anki_tags), ...] assert len(tuples) == 2 apkg = tmp_path / "rt.apkg" @@ -272,7 +272,9 @@ def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path): apkg, decks={20: "RT Deck"}, models={9: ["Front", "Back"]}, - notes=[(100 + i, 9, [f, b], t) for i, (f, b, t) in enumerate(tuples)], + # anki_tags is a list; the apkg tags field is space-joined. + notes=[(100 + i, 9, [f, b], " ".join(tags)) + for i, (f, b, tags) in enumerate(tuples)], cards=[(100 + i, 20) for i in range(len(tuples))], ) diff --git a/claude-templates/.ai/scripts/tests/test_flashcard_stats.py b/claude-templates/.ai/scripts/tests/test_flashcard_stats.py index 606f7c1..46deccc 100644 --- a/claude-templates/.ai/scripts/tests/test_flashcard_stats.py +++ b/claude-templates/.ai/scripts/tests/test_flashcard_stats.py @@ -217,6 +217,31 @@ def test_parse_cards_captures_body_without_drawer_planning_or_answer_header(stat assert c["body"] == "the real answer" +def test_parse_cards_counts_a_multitag_heading_as_a_card(stats): + """A card multi-tagged :fundamental:drill: still counts; the front is clean.""" + text = "* Sec\n** Q multi? :fundamental:drill:\nthe answer\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert len(cards) == 1 + assert cards[0]["heading"] == "Q multi?" + assert cards[0]["body"] == "the answer" + + +def test_parse_cards_ignores_a_tagged_heading_without_drill(stats): + """A tagged heading missing :drill: is not a drill card.""" + text = "* Sec\n** Just a note :note:\nbody\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert cards == [] + + +def test_parse_cards_body_stops_at_next_multitag_card(stats): + """The body scan ends at the next L2 card even when it is multi-tagged.""" + text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n" + cards, _ = stats.parse_cards(text.splitlines()) + assert len(cards) == 2 + assert cards[0]["body"] == "body1" + assert cards[1]["body"] == "body2" + + def test_find_duplicate_fronts_matches_normalized_headings(stats): cards = [ {"heading": "What is LEO?"}, diff --git a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py index 87008a8..fa38b64 100644 --- a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py +++ b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py @@ -158,17 +158,18 @@ Geostationary Earth Orbit. def test_parse_returns_front_back_tag_per_card(drill): cards = drill.parse(SECTIONED) assert len(cards) == 2 - assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes") + # The section becomes the sole Anki tag (as a one-element list). + assert cards[0] == ("What is LEO?", "Low Earth Orbit.", ["orbital-regimes"]) assert cards[1][0] == "What is GEO?" def test_parse_card_without_a_section_gets_the_drill_tag(drill): - assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")] + assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", ["drill"])] def test_parse_strips_properties_drawer_from_back(drill): text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n" - assert drill.parse(text) == [("Q?", "The answer.", "drill")] + assert drill.parse(text) == [("Q?", "The answer.", ["drill"])] def test_parse_trims_leading_and_trailing_blank_body_lines(drill): @@ -178,7 +179,59 @@ def test_parse_trims_leading_and_trailing_blank_body_lines(drill): def test_parse_card_with_only_a_drawer_has_empty_back(drill): text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n" - assert drill.parse(text) == [("Q?", "", "drill")] + assert drill.parse(text) == [("Q?", "", ["drill"])] + + +# --- multi-tag headings, --tag-filter, --guid-salt ------------------------- + +MULTITAG = """* Fundamentals +** What is LEO? :fundamental:drill: +Low Earth Orbit. +** What is GEO? :drill: +Geostationary Earth Orbit. +""" + + +def test_parse_multitag_heading_is_a_card_when_drill_is_present(drill): + """A heading with a second org tag still parses when drill is among them.""" + cards = drill.parse(MULTITAG) + assert len(cards) == 2 + assert cards[0][0] == "What is LEO?" + + +def test_parse_multitag_tags_ride_along_next_to_the_section_tag(drill): + """Non-drill org tags become Anki tags alongside the section tag.""" + cards = drill.parse(MULTITAG) + assert cards[0][2] == ["fundamentals", "fundamental"] # section slug + org tag + assert cards[1][2] == ["fundamentals"] # drill-only -> section only + + +def test_parse_heading_without_drill_tag_is_not_a_card(drill): + """A tagged heading missing :drill: is not a card (e.g. :note:).""" + assert drill.parse("* S\n** Just a note :note:\nbody\n") == [] + + +def test_parse_tag_filter_returns_only_cards_with_that_org_tag(drill): + """--tag-filter narrows to cards carrying the given org tag.""" + cards = drill.parse(MULTITAG, tag_filter="fundamental") + assert len(cards) == 1 + assert cards[0][0] == "What is LEO?" + + +def test_parse_body_bounded_by_any_l1_or_l2_heading(drill): + """A card body stops at the next L1/L2 heading, multi-tagged or not.""" + text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n" + cards = drill.parse(text) + assert cards[0][1] == "body1" + assert cards[1][1] == "body2" + + +def test_card_guid_salt_changes_the_guid(drill, monkeypatch): + """--guid-salt gives a subset deck its own GUID space; no salt is unchanged.""" + monkeypatch.setattr(drill.genanki, "guid_for", lambda *a: ":".join(a), raising=False) + assert drill.card_guid("front", None) == "front" + assert drill.card_guid("front", "fundamentals") == "fundamentals:front" + assert drill.card_guid("front", None) != drill.card_guid("front", "fundamentals") def test_parse_joins_multiline_body_with_br(drill): -- cgit v1.2.3