Break A and E technical into subdecks

This commit is contained in:
2026-06-18 17:46:33 +02:00
parent 6a4db1d889
commit 8fc3c0c32e
5 changed files with 174 additions and 47 deletions
+12 -6
View File
@@ -44,8 +44,8 @@ BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data/<slug>/
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 `\(...\)`
+32 -13
View File
@@ -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.
+19 -9
View File
@@ -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
+69 -19
View File
@@ -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=(",", ":")),
"{}",
),
+42
View File
@@ -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"