diff --git a/CLAUDE.md b/CLAUDE.md index 7c9b484..0021ad0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,8 +44,8 @@ BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data// data/ ──[Stage 2: amateurfunk_anki.py]──► anki/ ├── amateurfunk-technische-kenntnisse-n.apkg - ├── amateurfunk-technische-kenntnisse-e.apkg - ├── amateurfunk-technische-kenntnisse-a.apkg + ├── amateurfunk-technische-kenntnisse-e.apkg (one file, 11 topic sub-decks) + ├── amateurfunk-technische-kenntnisse-a.apkg (one file, 11 topic sub-decks) ├── amateurfunk-betriebliche-kenntnisse.apkg └── amateurfunk-kenntnisse-von-vorschriften.apkg @@ -76,10 +76,16 @@ technical.json ──[Stage 2c: amateurfunk_technical.py]──► anki/ `manifest-latest.json` to a per-edition directory). 2. Split the catalog into five categories. Betriebliche and Vorschriften get one deck each (shared across every candidate). - Technische is additionally fanned out per license class into three - decks (N / E / A) using a strict equality split on the question's - `class` field. The `klasse-N|E|A` tag is still emitted on every - note for inside-Anki filtering. + Technische is additionally fanned out per license class using a + strict equality split on the question's `class` field: one `.apkg` + each for N, E, and A. The E (463) and A (716) packages — the large + pools — are each built as a deck *tree*: one sub-deck per + first-level catalog topic (the 11 subsections) under an anchoring + `Technische Kenntnisse::E` / `::A` parent, so each imports as a + single file but studies topic by topic. N stays a single flat deck. + The set of split classes is `TOPIC_SPLIT_CLASSES`. The + `klasse-N|E|A` tag is still emitted on every note for inside-Anki + filtering. 3. Render every question as an Anki note: shuffled A/B/C/D choices on the front, the displayed position of the correct answer on the back. Inline `$...$` LaTeX is converted to MathJax `\(...\)` diff --git a/DESIGN.md b/DESIGN.md index 73d5252..81f589f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -494,25 +494,44 @@ output artifacts. ``` anki/ amateurfunk-technische-kenntnisse-n.apkg (195 cards) - amateurfunk-technische-kenntnisse-e.apkg (463 cards) - amateurfunk-technische-kenntnisse-a.apkg (716 cards) + amateurfunk-technische-kenntnisse-e.apkg (463 cards, 11 sub-decks) + amateurfunk-technische-kenntnisse-a.apkg (716 cards, 11 sub-decks) amateurfunk-betriebliche-kenntnisse.apkg (172 cards) amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards) ``` -Five `.apkg` files. Betriebliche and Vorschriften are shared across -every candidate (class-1-only in the data per §3 axis 2) and stay as -one deck each. Technische is fanned out per license class using a -**strict equality split** on the question's `class` field — class-1 -questions land in the N deck only, class-2 in E only, class-3 in A -only. The card counts therefore equal the new-at-this-class slices -from §3, not the cumulative study pools: a candidate studying for -class E imports Technische-N + Technische-E + Betriebliche + -Vorschriften. +Betriebliche and Vorschriften are shared across every candidate +(class-1-only in the data per §3 axis 2) and stay as one deck each. +Technische is fanned out per license class using a **strict equality +split** on the question's `class` field — class-1 questions land in +the N deck only, class-2 in E only, class-3 in A only. The card counts +therefore equal the new-at-this-class slices from §3, not the +cumulative study pools: a candidate studying for class E imports +Technische-N + Technische-E + Betriebliche + Vorschriften. + +The class-E (463) and class-A (716) Technische pools are large enough +that a single flat deck is unwieldy, so each package is built as a deck +*tree* rather than fanned out into separate files: one `.apkg` +containing one sub-deck per first-level catalog topic (the 11 +subsections under the Prüfungsteil, e.g. "Sender und Empfänger", +"Antennen und Übertragungsleitungen") plus an anchoring `…::E` / `…::A` +parent deck. Each card is filed under its topic via the `did` column; +grouping is by the first section title below the Prüfungsteil +(`item.path[1]`). Anki sorts the sub-decks alphabetically on import (it +has no per-deck manual ordering), not in catalog order. N (195) is +small enough to stay one flat deck. The split classes are listed in +`TOPIC_SPLIT_CLASSES`. These multi-deck packages list every sub-deck in +the `col.decks` blob (see `build_apkg_for_category` and +`insert_collection_metadata`). + +Note that re-importing a reworked package does not move cards that +already exist in a collection — Anki only files *new* cards by the +package's decks and leaves existing cards in their current deck. The Technische deck names use Anki's `::` hierarchy separator -(`Amateurfunk::Technische Kenntnisse::N`) so the three decks render -as children of a shared parent in Anki's deck browser. The +(`Amateurfunk::Technische Kenntnisse::N`, and one level deeper for E/A — +`Amateurfunk::Technische Kenntnisse::A::Sender und Empfänger`) so the +decks render as a nested tree in Anki's deck browser. The `klasse-N` / `klasse-E` / `klasse-A` tag is still emitted on every note — redundant within each Technische deck but useful in Betr/Vor for in-Anki filtering, and harmless besides. diff --git a/README.md b/README.md index 9508866..475a165 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,12 @@ Output: seven `.apkg` files under `anki/`. Vorschriften get one deck each (shared across all license classes); Technische is split per class into three decks (N / E / A) following the catalog's `class` field. A class-A candidate who wants every -Technische question imports all three Technische decks. +Technische question imports all three Technische decks. The E (463) +and A (716) decks are large pools, so each ships as a deck *tree* — +one sub-deck per first-level exam topic under `Technische Kenntnisse::E` +/ `::A` — so you can study them topic by topic instead of as one giant +deck. Each is still a single `.apkg`; N (195) stays flat (small enough +that sub-decks would add clutter without helping). **Two glossary decks** of radio shorthand — Q-groups and operating abbreviations, and technical/HAM abbreviations — built from curated @@ -45,18 +50,23 @@ belongs to: **Technical (one deck per license class):** -| Class | Section name | ID prefix | Questions | -|-------|---------------------------|-----------|----------:| -| N | Technische Kenntnisse (N) | `N*` | 195 | -| E | Technische Kenntnisse (E) | `E*` | 463 | -| A | Technische Kenntnisse (A) | `A*` | 716 | +| Class | Section name | ID prefix | Questions | Layout | +|-------|---------------------------|-----------|----------:|--------| +| N | Technische Kenntnisse (N) | `N*` | 195 | one flat deck | +| E | Technische Kenntnisse (E) | `E*` | 463 | 11 topic sub-decks | +| A | Technische Kenntnisse (A) | `A*` | 716 | 11 topic sub-decks | Counts are from the current edition (3. Auflage, März 2024; ~1750 -questions total). The license tiers are cumulative for the exam: +questions total). The class-E and class-A decks are each split into one +sub-deck per first-level exam topic (the 11 catalog subsections — +*Bauteile*, *Sender und Empfänger*, *Antennen …*, etc.) because those +pools are unwieldy as a single deck; each is still one `.apkg` +containing a deck tree. Anki sorts those sub-decks alphabetically on +import, not in exam order. The license tiers are cumulative for the exam: a class-E candidate is responsible for `N*` + `E*` + `B*` + `V*`; a class-A candidate is responsible for everything. Filter inside -Anki by deck, by the `klasse-N|E|A` tag, or by the `Number` field -prefix. +Anki by deck, by the `klasse-N|E|A` tag, or — for the topic within a +deck — by the `pfad-*` tag, or by the `Number` field prefix. ## Glossary decks diff --git a/amateurfunk_anki.py b/amateurfunk_anki.py index 4418459..3dcb55b 100644 --- a/amateurfunk_anki.py +++ b/amateurfunk_anki.py @@ -103,6 +103,14 @@ CLASS_ORDER = [("1", "N"), ("2", "E"), ("3", "A")] # shared across all candidates and stay as a single deck each. TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse" +# License classes whose Technische package is built with one sub-deck +# per first-level catalog topic (the 11 subsections under the +# Prüfungsteil) instead of a single flat deck. The E (463) and A (716) +# pools are large enough that a single deck is unwieldy; N (195) stays +# a single flat deck. Still one `.apkg` per class — the sub-decks live +# inside the package as a deck tree. +TOPIC_SPLIT_CLASSES = frozenset({"E", "A"}) + # Fallback build epoch if neither the manifest nor `--epoch` supplies # one. Picked as 0 so missing-metadata builds are still deterministic # and obviously wrong (timestamps would all show 1970). @@ -145,13 +153,17 @@ class Category: `title` is the original German title (with the Prüfungsteil prefix); `short_title` has the prefix stripped (used in the deck name and the slug); `questions` is the flat list of every question - that lives anywhere under this category. + that lives anywhere under this category. When `subdeck_by_topic` + is set, the package is built with one sub-deck per first-level + catalog topic instead of a single flat deck (see + `build_apkg_for_category`). """ title: str short_title: str slug: str questions: list + subdeck_by_topic: bool = False # ============================================================================ @@ -355,7 +367,10 @@ def _split_by_class(title, short_title, questions): The short title uses Anki's `::` deck-hierarchy separator so the three decks render as children of a shared parent in Anki's deck - browser (e.g. `Amateurfunk::Technische Kenntnisse::N`). + browser (e.g. `Amateurfunk::Technische Kenntnisse::N`). The classes + in `TOPIC_SPLIT_CLASSES` (E, A) stay a single package each but are + built with one sub-deck per first-level topic — see + `build_apkg_for_category` and the `subdeck_by_topic` flag. """ base_slug = slugify(short_title) for digit, letter in CLASS_ORDER: @@ -368,6 +383,7 @@ def _split_by_class(title, short_title, questions): short_title=f"{short_title}::{letter}", slug=f"{base_slug}-{letter.lower()}", questions=subset, + subdeck_by_topic=(letter in TOPIC_SPLIT_CLASSES), ) @@ -843,8 +859,12 @@ def build_apkg_for_category( media = MediaRegistry(edition_dir / "svgs") - deck_name = f"Amateurfunk::{category.short_title}" - deck_id = stable_id("deck", deck_name) + # The category's own deck is always present (even empty, it anchors + # the tree). Sub-decks discovered per note are added on first use, + # in catalog order, so the package's deck list is deterministic. + root_deck_name = f"Amateurfunk::{category.short_title}" + root_deck_id = stable_id("deck", root_deck_name) + decks = {root_deck_name: root_deck_id} model_id = stable_id("model", "Amateurfunk Multiple Choice") try: @@ -858,11 +878,14 @@ def build_apkg_for_category( number = str(question.get("number", f"q{ordinal}")) if number in explanations: applied_explanations += 1 + deck_short = _deck_short_title_for_item(category, item) + deck_name = f"Amateurfunk::{deck_short}" + deck_id = decks.setdefault(deck_name, stable_id("deck", deck_name)) note_id = stable_id("note", f"{category.slug}:{number}") card_id = stable_id("card", f"{category.slug}:{number}") fields = [ number, - category.short_title, + deck_short, display_path(item.path), front, back, @@ -871,6 +894,7 @@ def build_apkg_for_category( notes.append({ "note_id": note_id, "card_id": card_id, + "deck_id": deck_id, "guid": stable_guid(f"{category.slug}:{number}"), "fields": FIELD_SEP.join(fields), "sort": number, @@ -882,8 +906,8 @@ def build_apkg_for_category( create_collection_db( db_path, - deck_id=deck_id, - deck_name=deck_name, + decks=decks, + cur_deck_id=root_deck_id, model_id=model_id, notes=notes, now=build_epoch, @@ -896,7 +920,8 @@ def build_apkg_for_category( return { "path": out_path, - "deck": deck_name, + "deck": root_deck_name, + "subdecks": len(decks) - 1, "questions": len(category.questions), "media": len(media_paths), "missing_media": sorted(set(media.missing)), @@ -904,6 +929,21 @@ def build_apkg_for_category( } +def _deck_short_title_for_item(category, item): + """Return the `::`-joined deck short title a card lands in. + + Normally the category's own short title. When the category has + `subdeck_by_topic` set, the first-level catalog topic (the section + title just below the Prüfungsteil, `item.path[1]`) is appended so + the card lands in a sub-deck such as + `Technische Kenntnisse::A::Sender und Empfänger`. Items with no + topic level fall back to the category deck so nothing is dropped. + """ + if category.subdeck_by_topic and len(item.path) > 1: + return f"{category.short_title}::{item.path[1]}" + return category.short_title + + def write_apkg(out_path, db_path, media_paths, build_epoch): """Write the Anki package ZIP atomically. @@ -1013,21 +1053,27 @@ def svg_with_white_background(svg_text): # ============================================================================ -def create_collection_db(db_path, deck_id, deck_name, model_id, notes, now): +def create_collection_db(db_path, decks, cur_deck_id, model_id, notes, now): """Create the `collection.anki2` SQLite database for one package. Uses the v11 schema, which modern Anki still understands (it upgrades the collection on first open). We pre-write a single `col` row with the JSON config blobs, then insert one `notes` row and one `cards` row per question. + + `decks` maps every deck name in this package to its id; a package + is usually a single deck, but the per-topic Technische package + carries one entry per sub-deck plus the anchoring parent. + `cur_deck_id` is the deck selected on open. Each note carries its + own `deck_id`, so cards land in the right sub-deck. """ conn = sqlite3.connect(db_path) try: create_schema(conn) insert_collection_metadata( conn, - deck_id=deck_id, - deck_name=deck_name, + decks=decks, + cur_deck_id=cur_deck_id, model_id=model_id, now=now, ) @@ -1062,7 +1108,7 @@ def create_collection_db(db_path, deck_id, deck_name, model_id, notes, now): ( note["card_id"], note["note_id"], - deck_id, + note["deck_id"], 0, now, -1, @@ -1163,12 +1209,19 @@ def create_schema(conn): ) -def insert_collection_metadata(conn, deck_id, deck_name, model_id, now): +def insert_collection_metadata(conn, decks, cur_deck_id, model_id, now): """Write the single `col` row that carries the JSON config blobs. `crt` is a seconds-epoch creation time; `mod` and `scm` are in - milliseconds (Anki's mixed convention, not ours to fix). + milliseconds (Anki's mixed convention, not ours to fix). `decks` + maps deck name → id; every entry is written to `col.decks` so a + package can ship a deck tree, not just one deck. `cur_deck_id` is + the deck made current in `col.conf`. """ + decks_json = { + str(deck_id): deck_json(deck_id, deck_name, now) + for deck_name, deck_id in decks.items() + } conn.execute( """ INSERT INTO col @@ -1184,15 +1237,12 @@ def insert_collection_metadata(conn, deck_id, deck_name, model_id, now): 0, 0, 0, - json.dumps(collection_conf(deck_id), separators=(",", ":")), + json.dumps(collection_conf(cur_deck_id), separators=(",", ":")), json.dumps( {str(model_id): model_json(model_id, now)}, separators=(",", ":"), ), - json.dumps( - {str(deck_id): deck_json(deck_id, deck_name, now)}, - separators=(",", ":"), - ), + json.dumps(decks_json, separators=(",", ":")), json.dumps(default_deck_conf(now), separators=(",", ":")), "{}", ), diff --git a/test_amateurfunk_anki.py b/test_amateurfunk_anki.py index 9ed3bb2..adecff7 100644 --- a/test_amateurfunk_anki.py +++ b/test_amateurfunk_anki.py @@ -193,6 +193,48 @@ class TestAnkiBuild(unittest.TestCase): {"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]}, ) + def test_split_class_packages_ship_topic_subdeck_tree(self): + # The E and A packages each stay one file but carry a deck tree: + # the anchoring class deck plus one sub-deck per first-level + # topic, with each card filed under its topic sub-deck. + self._build_all() + for letter in ("e", "a"): + apkg = self.out_dir / f"amateurfunk-technische-kenntnisse-{letter}.apkg" + db_path, _media, _names = extract_collection(apkg, self.root / letter) + conn = sqlite3.connect(db_path) + try: + decks = json.loads( + conn.execute("select decks from col").fetchone()[0] + ) + deck_names = {d["name"] for d in decks.values()} + id_to_name = {int(k): d["name"] for k, d in decks.items()} + card_decks = sorted( + id_to_name[row[0]] + for row in conn.execute("select did from cards") + ) + finally: + conn.close() + root = f"Amateurfunk::Technische Kenntnisse::{letter.upper()}" + self.assertEqual(deck_names, {root, f"{root}::Grundlagen"}) + self.assertEqual(card_decks, [f"{root}::Grundlagen"]) + + def test_single_topic_classes_ship_one_flat_deck(self): + # N (and E) are not split: one deck, card filed directly in it. + self._build_all() + apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg" + db_path, _media, _names = extract_collection(apkg, self.root / "n") + conn = sqlite3.connect(db_path) + try: + decks = json.loads( + conn.execute("select decks from col").fetchone()[0] + ) + finally: + conn.close() + self.assertEqual( + {d["name"] for d in decks.values()}, + {"Amateurfunk::Technische Kenntnisse::N"}, + ) + def test_apkg_contains_notes_cards_and_media(self): self._build_all() apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"