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
+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=(",", ":")),
"{}",
),