Break A and E technical into subdecks
This commit is contained in:
@@ -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 `\(...\)`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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=(",", ":")),
|
||||
"{}",
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user