diff options
Diffstat (limited to 'docs/design')
| -rwxr-xr-x | docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py | 246 | ||||
| -rw-r--r-- | docs/design/2026-06-21-anki-titlefix-test.py | 190 |
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("&", "&") - .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()) 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 & b < c > 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 "<" becomes "&lt;", - # not silently treated as an already-escaped entity. - assert drill.escape_html("<") == "&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") == [] |
