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/
|
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
||||||
├── amateurfunk-technische-kenntnisse-n.apkg
|
├── amateurfunk-technische-kenntnisse-n.apkg
|
||||||
├── amateurfunk-technische-kenntnisse-e.apkg
|
├── amateurfunk-technische-kenntnisse-e.apkg (one file, 11 topic sub-decks)
|
||||||
├── amateurfunk-technische-kenntnisse-a.apkg
|
├── amateurfunk-technische-kenntnisse-a.apkg (one file, 11 topic sub-decks)
|
||||||
├── amateurfunk-betriebliche-kenntnisse.apkg
|
├── amateurfunk-betriebliche-kenntnisse.apkg
|
||||||
└── amateurfunk-kenntnisse-von-vorschriften.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).
|
`manifest-latest.json` to a per-edition directory).
|
||||||
2. Split the catalog into five categories. Betriebliche and
|
2. Split the catalog into five categories. Betriebliche and
|
||||||
Vorschriften get one deck each (shared across every candidate).
|
Vorschriften get one deck each (shared across every candidate).
|
||||||
Technische is additionally fanned out per license class into three
|
Technische is additionally fanned out per license class using a
|
||||||
decks (N / E / A) using a strict equality split on the question's
|
strict equality split on the question's `class` field: one `.apkg`
|
||||||
`class` field. The `klasse-N|E|A` tag is still emitted on every
|
each for N, E, and A. The E (463) and A (716) packages — the large
|
||||||
note for inside-Anki filtering.
|
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
|
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
|
the front, the displayed position of the correct answer on the
|
||||||
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
|
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
|
||||||
|
|||||||
@@ -494,25 +494,44 @@ output artifacts.
|
|||||||
```
|
```
|
||||||
anki/
|
anki/
|
||||||
amateurfunk-technische-kenntnisse-n.apkg (195 cards)
|
amateurfunk-technische-kenntnisse-n.apkg (195 cards)
|
||||||
amateurfunk-technische-kenntnisse-e.apkg (463 cards)
|
amateurfunk-technische-kenntnisse-e.apkg (463 cards, 11 sub-decks)
|
||||||
amateurfunk-technische-kenntnisse-a.apkg (716 cards)
|
amateurfunk-technische-kenntnisse-a.apkg (716 cards, 11 sub-decks)
|
||||||
amateurfunk-betriebliche-kenntnisse.apkg (172 cards)
|
amateurfunk-betriebliche-kenntnisse.apkg (172 cards)
|
||||||
amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards)
|
amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards)
|
||||||
```
|
```
|
||||||
|
|
||||||
Five `.apkg` files. Betriebliche and Vorschriften are shared across
|
Betriebliche and Vorschriften are shared across every candidate
|
||||||
every candidate (class-1-only in the data per §3 axis 2) and stay as
|
(class-1-only in the data per §3 axis 2) and stay as one deck each.
|
||||||
one deck each. Technische is fanned out per license class using a
|
Technische is fanned out per license class using a **strict equality
|
||||||
**strict equality split** on the question's `class` field — class-1
|
split** on the question's `class` field — class-1 questions land in
|
||||||
questions land in the N deck only, class-2 in E only, class-3 in A
|
the N deck only, class-2 in E only, class-3 in A only. The card counts
|
||||||
only. The card counts therefore equal the new-at-this-class slices
|
therefore equal the new-at-this-class slices from §3, not the
|
||||||
from §3, not the cumulative study pools: a candidate studying for
|
cumulative study pools: a candidate studying for class E imports
|
||||||
class E imports Technische-N + Technische-E + Betriebliche +
|
Technische-N + Technische-E + Betriebliche + Vorschriften.
|
||||||
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
|
The Technische deck names use Anki's `::` hierarchy separator
|
||||||
(`Amateurfunk::Technische Kenntnisse::N`) so the three decks render
|
(`Amateurfunk::Technische Kenntnisse::N`, and one level deeper for E/A —
|
||||||
as children of a shared parent in Anki's deck browser. The
|
`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
|
`klasse-N` / `klasse-E` / `klasse-A` tag is still emitted on every
|
||||||
note — redundant within each Technische deck but useful in Betr/Vor
|
note — redundant within each Technische deck but useful in Betr/Vor
|
||||||
for in-Anki filtering, and harmless besides.
|
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);
|
Vorschriften get one deck each (shared across all license classes);
|
||||||
Technische is split per class into three decks (N / E / A) following
|
Technische is split per class into three decks (N / E / A) following
|
||||||
the catalog's `class` field. A class-A candidate who wants every
|
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
|
**Two glossary decks** of radio shorthand — Q-groups and operating
|
||||||
abbreviations, and technical/HAM abbreviations — built from curated
|
abbreviations, and technical/HAM abbreviations — built from curated
|
||||||
@@ -45,18 +50,23 @@ belongs to:
|
|||||||
|
|
||||||
**Technical (one deck per license class):**
|
**Technical (one deck per license class):**
|
||||||
|
|
||||||
| Class | Section name | ID prefix | Questions |
|
| Class | Section name | ID prefix | Questions | Layout |
|
||||||
|-------|---------------------------|-----------|----------:|
|
|-------|---------------------------|-----------|----------:|--------|
|
||||||
| N | Technische Kenntnisse (N) | `N*` | 195 |
|
| N | Technische Kenntnisse (N) | `N*` | 195 | one flat deck |
|
||||||
| E | Technische Kenntnisse (E) | `E*` | 463 |
|
| E | Technische Kenntnisse (E) | `E*` | 463 | 11 topic sub-decks |
|
||||||
| A | Technische Kenntnisse (A) | `A*` | 716 |
|
| A | Technische Kenntnisse (A) | `A*` | 716 | 11 topic sub-decks |
|
||||||
|
|
||||||
Counts are from the current edition (3. Auflage, März 2024; ~1750
|
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-E candidate is responsible for `N*` + `E*` + `B*` + `V*`;
|
||||||
a class-A candidate is responsible for everything. Filter inside
|
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
|
Anki by deck, by the `klasse-N|E|A` tag, or — for the topic within a
|
||||||
prefix.
|
deck — by the `pfad-*` tag, or by the `Number` field prefix.
|
||||||
|
|
||||||
## Glossary decks
|
## 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.
|
# shared across all candidates and stay as a single deck each.
|
||||||
TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse"
|
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
|
# Fallback build epoch if neither the manifest nor `--epoch` supplies
|
||||||
# one. Picked as 0 so missing-metadata builds are still deterministic
|
# one. Picked as 0 so missing-metadata builds are still deterministic
|
||||||
# and obviously wrong (timestamps would all show 1970).
|
# and obviously wrong (timestamps would all show 1970).
|
||||||
@@ -145,13 +153,17 @@ class Category:
|
|||||||
`title` is the original German title (with the Prüfungsteil
|
`title` is the original German title (with the Prüfungsteil
|
||||||
prefix); `short_title` has the prefix stripped (used in the deck
|
prefix); `short_title` has the prefix stripped (used in the deck
|
||||||
name and the slug); `questions` is the flat list of every question
|
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
|
title: str
|
||||||
short_title: str
|
short_title: str
|
||||||
slug: str
|
slug: str
|
||||||
questions: list
|
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
|
The short title uses Anki's `::` deck-hierarchy separator so the
|
||||||
three decks render as children of a shared parent in Anki's deck
|
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)
|
base_slug = slugify(short_title)
|
||||||
for digit, letter in CLASS_ORDER:
|
for digit, letter in CLASS_ORDER:
|
||||||
@@ -368,6 +383,7 @@ def _split_by_class(title, short_title, questions):
|
|||||||
short_title=f"{short_title}::{letter}",
|
short_title=f"{short_title}::{letter}",
|
||||||
slug=f"{base_slug}-{letter.lower()}",
|
slug=f"{base_slug}-{letter.lower()}",
|
||||||
questions=subset,
|
questions=subset,
|
||||||
|
subdeck_by_topic=(letter in TOPIC_SPLIT_CLASSES),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -843,8 +859,12 @@ def build_apkg_for_category(
|
|||||||
|
|
||||||
media = MediaRegistry(edition_dir / "svgs")
|
media = MediaRegistry(edition_dir / "svgs")
|
||||||
|
|
||||||
deck_name = f"Amateurfunk::{category.short_title}"
|
# The category's own deck is always present (even empty, it anchors
|
||||||
deck_id = stable_id("deck", deck_name)
|
# 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")
|
model_id = stable_id("model", "Amateurfunk Multiple Choice")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -858,11 +878,14 @@ def build_apkg_for_category(
|
|||||||
number = str(question.get("number", f"q{ordinal}"))
|
number = str(question.get("number", f"q{ordinal}"))
|
||||||
if number in explanations:
|
if number in explanations:
|
||||||
applied_explanations += 1
|
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}")
|
note_id = stable_id("note", f"{category.slug}:{number}")
|
||||||
card_id = stable_id("card", f"{category.slug}:{number}")
|
card_id = stable_id("card", f"{category.slug}:{number}")
|
||||||
fields = [
|
fields = [
|
||||||
number,
|
number,
|
||||||
category.short_title,
|
deck_short,
|
||||||
display_path(item.path),
|
display_path(item.path),
|
||||||
front,
|
front,
|
||||||
back,
|
back,
|
||||||
@@ -871,6 +894,7 @@ def build_apkg_for_category(
|
|||||||
notes.append({
|
notes.append({
|
||||||
"note_id": note_id,
|
"note_id": note_id,
|
||||||
"card_id": card_id,
|
"card_id": card_id,
|
||||||
|
"deck_id": deck_id,
|
||||||
"guid": stable_guid(f"{category.slug}:{number}"),
|
"guid": stable_guid(f"{category.slug}:{number}"),
|
||||||
"fields": FIELD_SEP.join(fields),
|
"fields": FIELD_SEP.join(fields),
|
||||||
"sort": number,
|
"sort": number,
|
||||||
@@ -882,8 +906,8 @@ def build_apkg_for_category(
|
|||||||
|
|
||||||
create_collection_db(
|
create_collection_db(
|
||||||
db_path,
|
db_path,
|
||||||
deck_id=deck_id,
|
decks=decks,
|
||||||
deck_name=deck_name,
|
cur_deck_id=root_deck_id,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
now=build_epoch,
|
now=build_epoch,
|
||||||
@@ -896,7 +920,8 @@ def build_apkg_for_category(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"path": out_path,
|
"path": out_path,
|
||||||
"deck": deck_name,
|
"deck": root_deck_name,
|
||||||
|
"subdecks": len(decks) - 1,
|
||||||
"questions": len(category.questions),
|
"questions": len(category.questions),
|
||||||
"media": len(media_paths),
|
"media": len(media_paths),
|
||||||
"missing_media": sorted(set(media.missing)),
|
"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):
|
def write_apkg(out_path, db_path, media_paths, build_epoch):
|
||||||
"""Write the Anki package ZIP atomically.
|
"""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.
|
"""Create the `collection.anki2` SQLite database for one package.
|
||||||
|
|
||||||
Uses the v11 schema, which modern Anki still understands (it
|
Uses the v11 schema, which modern Anki still understands (it
|
||||||
upgrades the collection on first open). We pre-write a single
|
upgrades the collection on first open). We pre-write a single
|
||||||
`col` row with the JSON config blobs, then insert one `notes`
|
`col` row with the JSON config blobs, then insert one `notes`
|
||||||
row and one `cards` row per question.
|
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)
|
conn = sqlite3.connect(db_path)
|
||||||
try:
|
try:
|
||||||
create_schema(conn)
|
create_schema(conn)
|
||||||
insert_collection_metadata(
|
insert_collection_metadata(
|
||||||
conn,
|
conn,
|
||||||
deck_id=deck_id,
|
decks=decks,
|
||||||
deck_name=deck_name,
|
cur_deck_id=cur_deck_id,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
@@ -1062,7 +1108,7 @@ def create_collection_db(db_path, deck_id, deck_name, model_id, notes, now):
|
|||||||
(
|
(
|
||||||
note["card_id"],
|
note["card_id"],
|
||||||
note["note_id"],
|
note["note_id"],
|
||||||
deck_id,
|
note["deck_id"],
|
||||||
0,
|
0,
|
||||||
now,
|
now,
|
||||||
-1,
|
-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.
|
"""Write the single `col` row that carries the JSON config blobs.
|
||||||
|
|
||||||
`crt` is a seconds-epoch creation time; `mod` and `scm` are in
|
`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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO col
|
INSERT INTO col
|
||||||
@@ -1184,15 +1237,12 @@ def insert_collection_metadata(conn, deck_id, deck_name, model_id, now):
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
json.dumps(collection_conf(deck_id), separators=(",", ":")),
|
json.dumps(collection_conf(cur_deck_id), separators=(",", ":")),
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{str(model_id): model_json(model_id, now)},
|
{str(model_id): model_json(model_id, now)},
|
||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
),
|
),
|
||||||
json.dumps(
|
json.dumps(decks_json, separators=(",", ":")),
|
||||||
{str(deck_id): deck_json(deck_id, deck_name, now)},
|
|
||||||
separators=(",", ":"),
|
|
||||||
),
|
|
||||||
json.dumps(default_deck_conf(now), separators=(",", ":")),
|
json.dumps(default_deck_conf(now), separators=(",", ":")),
|
||||||
"{}",
|
"{}",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -193,6 +193,48 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
{"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]},
|
{"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):
|
def test_apkg_contains_notes_cards_and_media(self):
|
||||||
self._build_all()
|
self._build_all()
|
||||||
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
|
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
|
||||||
|
|||||||
Reference in New Issue
Block a user