aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-24 06:47:07 -0400
committerCraig Jennings <c@cjennings.net>2026-06-24 06:47:07 -0400
commit060a938b9629b134b9110db4d1cbc99577ee5674 (patch)
tree0e240a6b44fce13fc8d33f019cef0fe8e01e4bbb /.ai/scripts
parent0d87c80b3377e47b1ba56dc38e1f7162fd0ef9d7 (diff)
downloadrulesets-060a938b9629b134b9110db4d1cbc99577ee5674.tar.gz
rulesets-060a938b9629b134b9110db4d1cbc99577ee5674.zip
fix(flashcard): name the Anki deck from #+TITLE, not the filename
flashcard-to-anki.py's default_deck_name returned the input basename, so a deck built through flashcard-sync (which passes no --deck) was named after the file slug (refutation-drill) instead of the curated #+TITLE the phone deck should read (Refutations). flashcard-review.org already documented the #+TITLE behavior, and the script never matched it. default_deck_name now scans for a #+TITLE line (case-insensitive, trimmed) and falls back to the basename when there's none. Five new tests cover title-drives-name, trimming, case-insensitivity, and the two basename fallbacks. The two old tests that asserted basename-always are replaced. The pre-staged script and test (validated 2026-06-21) applied cleanly red-to-green; their redundant copies are removed and the rationale doc kept. Migration: deck IDs derive from the name, so decks previously built without --deck land as new decks on the next import. Old basename-named decks keep their history and can be deleted by hand. Claude-Session: https://claude.ai/code/session_017PtX1nt1rtYVATuzmzBS4f
Diffstat (limited to '.ai/scripts')
-rwxr-xr-x.ai/scripts/flashcard-to-anki.py26
-rw-r--r--.ai/scripts/tests/test_flashcard_to_anki.py31
2 files changed, 45 insertions, 12 deletions
diff --git a/.ai/scripts/flashcard-to-anki.py b/.ai/scripts/flashcard-to-anki.py
index 7227683..ca4c70b 100755
--- a/.ai/scripts/flashcard-to-anki.py
+++ b/.ai/scripts/flashcard-to-anki.py
@@ -13,9 +13,11 @@ Parses org-drill structure:
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.
+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
@@ -177,7 +179,19 @@ def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck:
return deck
-def default_deck_name(input_path: Path) -> str:
+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
@@ -197,7 +211,7 @@ def main() -> int:
)
parser.add_argument(
"--deck",
- help="Deck name. Defaults to the input basename.",
+ help="Deck name. Defaults to the org #+TITLE, or the input basename.",
)
parser.add_argument(
"--output",
@@ -213,7 +227,7 @@ def main() -> int:
return 1
org_text = input_path.read_text(encoding="utf-8")
- deck_name = args.deck or default_deck_name(input_path)
+ 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)
diff --git a/.ai/scripts/tests/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py
index 058b0cd..87008a8 100644
--- a/.ai/scripts/tests/test_flashcard_to_anki.py
+++ b/.ai/scripts/tests/test_flashcard_to_anki.py
@@ -34,14 +34,33 @@ def test_default_output_path_targets_phone_anki_dir(drill):
assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg"
-def test_default_deck_name_is_raw_basename(drill):
- """Deck name is the input basename with case preserved; #+TITLE is ignored."""
- assert drill.default_deck_name(Path("/x/deepsat.org")) == "deepsat"
+def test_default_deck_name_uses_org_title(drill):
+ """The #+TITLE drives the Anki deck name, not the filename slug."""
+ org = "#+TITLE: Refutations\n* Section\n** Q? :drill:\na\n"
+ assert drill.default_deck_name(Path("/x/refutation-drill.org"), org) == "Refutations"
-def test_default_deck_name_keeps_hyphens(drill):
- """A hyphenated basename is kept verbatim rather than title-cased."""
- assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill"
+def test_default_deck_name_title_is_trimmed(drill):
+ """Surrounding whitespace on the #+TITLE value is stripped."""
+ org = "#+TITLE: DeepSat Flashcards \n"
+ assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "DeepSat Flashcards"
+
+
+def test_default_deck_name_title_match_is_case_insensitive(drill):
+ """A lowercase #+title: keyword is still recognized."""
+ org = "#+title: Health Flashcards\n"
+ assert drill.default_deck_name(Path("/x/health-drill.org"), org) == "Health Flashcards"
+
+
+def test_default_deck_name_falls_back_to_basename_without_title(drill):
+ """No #+TITLE line falls back to the input basename, case preserved."""
+ org = "* Section\n** Q? :drill:\na\n"
+ assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "deepsat"
+
+
+def test_default_deck_name_blank_title_falls_back_to_basename(drill):
+ """An empty #+TITLE value is ignored in favour of the basename."""
+ assert drill.default_deck_name(Path("/x/health-drill.org"), "#+TITLE: \n") == "health-drill"
# --- section_to_tag (pure) ---