aboutsummaryrefslogtreecommitdiff
path: root/docs
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 /docs
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 'docs')
-rwxr-xr-xdocs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py246
-rw-r--r--docs/design/2026-06-21-anki-titlefix-test.py190
2 files changed, 0 insertions, 436 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("&", "&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, 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())
diff --git a/docs/design/2026-06-21-anki-titlefix-test.py b/docs/design/2026-06-21-anki-titlefix-test.py
deleted file mode 100644
index 87008a8..0000000
--- a/docs/design/2026-06-21-anki-titlefix-test.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""Tests for flashcard-to-anki.py default-path and deck-name helpers.
-
-The script is a PEP 723 uv-run script that imports genanki, which uv resolves
-at runtime but isn't installed in the test environment. The fixture stubs
-genanki in sys.modules so the module loads; the pure helpers under test never
-call into it.
-"""
-from __future__ import annotations
-
-import importlib.util
-import sys
-import types
-from pathlib import Path
-
-import pytest
-
-SCRIPT = Path(__file__).resolve().parents[1] / "flashcard-to-anki.py"
-
-
-@pytest.fixture(scope="module")
-def drill():
- # Only stub when genanki is genuinely absent, so a real install isn't shadowed.
- sys.modules.setdefault("genanki", types.ModuleType("genanki"))
- spec = importlib.util.spec_from_file_location("flashcard_to_anki", SCRIPT)
- assert spec and spec.loader
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
-
-
-def test_default_output_path_targets_phone_anki_dir(drill):
- """The .apkg is a phone artifact, so it defaults under sync/phone/anki/."""
- result = drill.default_output_path(Path("/home/x/projects/health/health-drill.org"))
- assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg"
-
-
-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_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) ---
-
-def test_section_to_tag_slugifies_words(drill):
- assert drill.section_to_tag("Orbital Regimes") == "orbital-regimes"
-
-
-def test_section_to_tag_strips_leading_and_trailing_nonalnum(drill):
- assert drill.section_to_tag(" People & Roles! ") == "people-roles"
-
-
-def test_section_to_tag_empty_string(drill):
- assert drill.section_to_tag("") == ""
-
-
-# --- escape_html (pure) ---
-
-def test_escape_html_escapes_amp_lt_gt(drill):
- assert drill.escape_html("a & b < c > d") == "a &amp; b &lt; c &gt; d"
-
-
-def test_escape_html_plain_text_unchanged(drill):
- assert drill.escape_html("plain text") == "plain text"
-
-
-def test_escape_html_escapes_amp_first_so_existing_entity_is_literal(drill):
- # & is replaced before < / >, so a literal "&lt;" becomes "&amp;lt;",
- # not silently treated as an already-escaped entity.
- assert drill.escape_html("&lt;") == "&amp;lt;"
-
-
-def test_escape_html_empty_string(drill):
- assert drill.escape_html("") == ""
-
-
-# --- stable_id (pure) ---
-
-def test_stable_id_is_deterministic(drill):
- assert drill.stable_id("DeepSat", "deck") == drill.stable_id("DeepSat", "deck")
-
-
-def test_stable_id_salt_changes_the_result(drill):
- assert drill.stable_id("DeepSat", "deck") != drill.stable_id("DeepSat", "model")
-
-
-def test_stable_id_stays_within_the_reserved_range(drill):
- value = drill.stable_id("anything", "deck")
- assert drill.ID_BASE <= value < drill.ID_BASE + drill.ID_RANGE
-
-
-# --- strip_org_metadata (pure) ---
-
-def test_strip_org_metadata_drops_properties_drawer(drill):
- body = [":PROPERTIES:", ":ID: x", ":END:", "real content"]
- assert drill.strip_org_metadata(body) == ["real content"]
-
-
-def test_strip_org_metadata_drops_planning_lines(drill):
- body = ["SCHEDULED: <2026-05-30>", "DEADLINE: <2026-06-01>",
- "CLOSED: [2026-05-29]", "body"]
- assert drill.strip_org_metadata(body) == ["body"]
-
-
-def test_strip_org_metadata_leaves_plain_body_unchanged(drill):
- body = ["line one", "line two"]
- assert drill.strip_org_metadata(body) == ["line one", "line two"]
-
-
-def test_strip_org_metadata_empty_list(drill):
- assert drill.strip_org_metadata([]) == []
-
-
-def test_strip_org_metadata_unclosed_drawer_swallows_the_rest(drill):
- # An unterminated :PROPERTIES: drawer consumes everything after it.
- body = [":PROPERTIES:", ":ID: x", "still in drawer"]
- assert drill.strip_org_metadata(body) == []
-
-
-def test_strip_org_metadata_drops_created_date_line(drill):
- # A created/added date never belongs on a card back.
- assert drill.strip_org_metadata(["Created: 2026-05-30", "real answer"]) == ["real answer"]
-
-
-# --- parse (pure, core parser) ---
-
-SECTIONED = """* Orbital Regimes
-** What is LEO? :drill:
-Low Earth Orbit.
-** What is GEO? :drill:
-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")
- 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")]
-
-
-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")]
-
-
-def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
- cards = drill.parse("** Q? :drill:\n\n\nanswer\n\n\n")
- assert cards[0][1] == "answer"
-
-
-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")]
-
-
-def test_parse_joins_multiline_body_with_br(drill):
- cards = drill.parse("** Q? :drill:\nline one\nline two\n")
- assert cards[0][1] == "line one<br>line two"
-
-
-def test_parse_no_drill_cards_returns_empty(drill):
- assert drill.parse("* Section\nno drill cards here\n") == []