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