Add glossary decks

This commit is contained in:
2026-06-17 11:48:27 +02:00
parent fb81e9c1aa
commit 2b73096d3c
9 changed files with 2748 additions and 11 deletions
+742
View File
@@ -0,0 +1,742 @@
#!/usr/bin/env python3
"""Build an Anki deck of Q-groups and operating abbreviations.
This is a sibling of `amateurfunk_anki.py`. Where that script turns the
BNetzA question catalog into multiple-choice decks, this one builds a
single reference deck from a hand-curated database (`shorthand.json`):
the Q-groups and operating abbreviations a candidate meets in the exam,
plus the most common ones used on the air that the exam never mentions
(so the deck teaches real operating knowledge, not just the test).
Design follows the four rules the deck was specified with:
1. Stable IDs. Every deck/model/note/card id and note GUID is hashed
from the displayed code form, so re-importing updates in place
instead of duplicating.
2. Two cards per code. Each note carries two templates — one prompts
for the meaning given the code, the reverse prompts for the code
given the meaning. Modelling them as a single *note* (rule 4) is
what lets one record drive both directions without us emitting two
hand-built cards.
3. Q-groups get one note per usage. A Q-group means one thing as a
statement (`QSO`) and another as a question (`QSO?`), so each
yields two notes — and therefore two forward/reverse pairs.
Plain abbreviations have only one form and yield one note.
Everything is stdlib only. The heavy lifting (SQLite schema, the apkg
ZIP writer, the deterministic timestamps, the ID hashing) is shared
with `amateurfunk_anki` by import, so the two stay in lockstep.
"""
import argparse
import datetime as dt
import html
import json
import re
import shutil
import sqlite3
import sys
from pathlib import Path
# Reuse the low-level Anki package machinery rather than copying it.
# These helpers are pure and model-agnostic; only the note type, the
# card templates, and the card-per-note fan-out are specific to this
# deck and are defined below.
from amateurfunk_anki import (
AnkiBuildError,
DEFAULT_BUILD_EPOCH,
FIELD_SEP,
build_epoch_from_manifest,
checksum_sort_field,
collection_conf,
create_schema,
deck_json,
default_deck_conf,
field_json,
load_latest_catalog,
slugify,
stable_guid,
stable_id,
text_html,
write_apkg,
)
# ============================================================================
# Constants
# ============================================================================
# Curated source database. Editorial content tracked in git, not a
# build artifact — same status as `explanations.json` in the sibling
# script.
DEFAULT_SHORTHAND_PATH = Path("shorthand.json")
# Default location of the fetcher's output. Only consulted to borrow a
# deterministic build epoch from the catalog manifest; the deck content
# itself does not depend on the catalog, so a missing data dir is not
# fatal here (unlike in `amateurfunk_anki`).
DEFAULT_DATA_DIR = Path("data")
# Default destination for the generated `.apkg` (shared with the
# multiple-choice decks).
DEFAULT_OUT_DIR = Path("anki")
# Human-facing deck name. The `Amateurfunk::` prefix nests it under the
# same top-level node as the catalog decks in Anki's browser.
DECK_NAME = "Amateurfunk::Abkürzungen & Q-Gruppen"
# Note-type name. Distinct from the multiple-choice model so importing
# both decks never merges the two note types.
MODEL_NAME = "Amateurfunk Q-Gruppe / Abkürzung"
# User-facing kind labels, shown on the card and used to build the
# `q-gruppe` / `abkuerzung` filter tags.
KIND_QGROUP = "Q-Gruppe"
KIND_ABBREV = "Abkürzung"
# Usage-form labels for Q-groups. A Q-group statement (`QSO`) and the
# matching question (`QSO?`) become two separate notes (rule 3).
FORM_STATEMENT = "Aussageform"
FORM_QUESTION = "Frageform"
# Exit codes (mirrors the sibling script's two-state convention).
EXIT_OK = 0
EXIT_ERROR = 1
# Fields allowed on a Q-group entry / an abbreviation entry in
# `shorthand.json`. Extras are rejected so a typo can't silently shift
# meaning onto a card that never renders it.
QGROUP_REQUIRED = ("code", "question", "statement")
QGROUP_OPTIONAL = ("exam", "explanation", "example", "tags")
ABBREV_REQUIRED = ("code", "meaning")
ABBREV_OPTIONAL = ("exam", "explanation", "example", "tags")
# Inline `` `code` `` spans in the curated examples become <code> tags.
BACKTICK_SPAN_RE = re.compile(r"`([^`]+)`")
# ============================================================================
# Loading and validating the curated database
# ============================================================================
def load_shorthand(path):
"""Return the validated `(q_codes, abbreviations)` from a JSON file.
The file is editorial content with a strict shape (see the field
allow-lists above). Unlike the explanations database in the sibling
script, this file is mandatory: there is no deck without it, so a
missing or malformed file is a hard error.
"""
try:
raw = json.loads(path.read_text("utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise AnkiBuildError(
f"could not read shorthand file {path}: {e}"
) from e
if not isinstance(raw, dict):
raise AnkiBuildError(
f"shorthand file {path} must contain a JSON object at the top level"
)
q_codes = raw.get("q_codes", [])
abbreviations = raw.get("abbreviations", [])
if not isinstance(q_codes, list) or not isinstance(abbreviations, list):
raise AnkiBuildError(
f"shorthand file {path}: 'q_codes' and 'abbreviations' must be lists"
)
for entry in q_codes:
validate_entry(entry, QGROUP_REQUIRED, QGROUP_OPTIONAL, "q_codes")
for entry in abbreviations:
validate_entry(entry, ABBREV_REQUIRED, ABBREV_OPTIONAL, "abbreviations")
_check_no_duplicate_codes(q_codes, abbreviations)
return q_codes, abbreviations
def validate_entry(entry, required, optional, where):
"""Raise `AnkiBuildError` if one database entry is malformed."""
if not isinstance(entry, dict):
raise AnkiBuildError(f"{where}: every entry must be a JSON object")
label = entry.get("code", "<no code>")
missing = [f for f in required if f not in entry]
if missing:
raise AnkiBuildError(
f"{where} entry {label!r} missing required fields: {missing}"
)
extra = sorted(set(entry) - set(required) - set(optional))
if extra:
raise AnkiBuildError(f"{where} entry {label!r}: unknown fields {extra}")
for field in required + ("explanation", "example"):
if field in entry and (
not isinstance(entry[field], str) or not entry[field].strip()
):
raise AnkiBuildError(
f"{where} entry {label!r}: {field!r} must be a non-empty string"
)
if "exam" in entry and not isinstance(entry["exam"], bool):
raise AnkiBuildError(
f"{where} entry {label!r}: 'exam' must be a boolean"
)
if "tags" in entry:
tags = entry["tags"]
if not isinstance(tags, list) or not all(
isinstance(t, str) and t.strip() for t in tags
):
raise AnkiBuildError(
f"{where} entry {label!r}: 'tags' must be a list of non-empty strings"
)
def _check_no_duplicate_codes(q_codes, abbreviations):
"""Raise if any displayed code form would collide.
Displayed forms are the per-note keys that every stable ID and GUID
is hashed from, so a duplicate would silently overwrite another
note on import. Q-groups expand to `CODE` and `CODE?`; abbreviations
stay as `CODE`.
"""
seen = {}
forms = [entry["code"] for entry in q_codes]
forms += [entry["code"] + "?" for entry in q_codes]
forms += [entry["code"] for entry in abbreviations]
duplicates = sorted({form for form in forms if forms.count(form) > 1})
if duplicates:
raise AnkiBuildError(
"duplicate code form(s) in shorthand database: "
+ ", ".join(duplicates)
)
return seen
# ============================================================================
# Note expansion
# ============================================================================
def build_notes(q_codes, abbreviations):
"""Expand the curated entries into the flat list of notes to emit.
Each Q-group becomes two notes (statement form `QSO`, question form
`QSO?`); each abbreviation becomes one. Order is stable: Q-groups in
file order first (statement then question for each), abbreviations
after, so the deck's `due` ordering is deterministic.
"""
notes = []
for entry in q_codes:
notes.append(
_note_record(
code_display=entry["code"],
meaning=entry["statement"],
kind=KIND_QGROUP,
form=FORM_STATEMENT,
entry=entry,
)
)
notes.append(
_note_record(
code_display=entry["code"] + "?",
meaning=entry["question"],
kind=KIND_QGROUP,
form=FORM_QUESTION,
entry=entry,
)
)
for entry in abbreviations:
notes.append(
_note_record(
code_display=entry["code"],
meaning=entry["meaning"],
kind=KIND_ABBREV,
form="",
entry=entry,
)
)
return notes
def make_note(code_display, meaning, kind, form, explanation, example, tags, namespace):
"""Render one two-card note (forward + reverse) for a glossary deck.
Shared by every glossary-style deck (operating shorthand here, the
technical-abbreviation deck in `amateurfunk_technical.py`). All
identity (`note_id`, `guid`, and the per-template card ids) is
hashed from `code_display` under `namespace`, so the two decks get
independent, stable IDs that never collide on import. The fields are
stored pre-escaped; the card templates only lay them out.
"""
fields = [
html.escape(code_display),
text_html(meaning),
kind,
form,
text_html(explanation) if explanation else "",
render_example(example) if example else "",
]
return {
"note_id": stable_id(f"{namespace}-note", code_display),
"guid": stable_guid(f"{namespace}:{code_display}"),
"fields": FIELD_SEP.join(fields),
"sort": code_display,
"tags": tags,
"card_ids": [
stable_id(f"{namespace}-card", f"{code_display}:{ord_}")
for ord_ in range(2)
],
}
def _note_record(code_display, meaning, kind, form, entry):
"""Build one operating-deck note under the `shorthand` ID namespace.
`code_display` is unique across the database (enforced by
`_check_no_duplicate_codes`), so it's a safe identity key.
"""
return make_note(
code_display,
meaning,
kind,
form,
entry.get("explanation"),
entry.get("example"),
tags_for_entry(kind, entry),
namespace="shorthand",
)
def render_example(example):
"""Render an example string: HTML-escape, then `code` → <code>.
The curated examples use Markdown-style backticks around the bits
that are literally keyed on the air (`QRL?`, `de DL1ABC k`). We
escape first via `text_html` (so the surrounding prose is safe and
newlines survive) and only then turn backtick spans into <code>.
"""
return BACKTICK_SPAN_RE.sub(
lambda m: f"<code>{m.group(1)}</code>", text_html(example)
)
def tags_for_entry(kind, entry):
"""Return the Anki tags field (space-padded) for one note.
A `q-gruppe`/`abkuerzung` kind tag, a `pruefung` tag when the code
appears in the exam catalog, and any extra editorial tags (e.g.
`prosign`, `notsignal`) carried on the entry.
"""
tags = ["q-gruppe" if kind == KIND_QGROUP else "abkuerzung"]
if entry.get("exam"):
tags.append("pruefung")
tags.extend(slugify(tag) for tag in entry.get("tags", []))
return " " + " ".join(tags) + " "
# ============================================================================
# Anki note type (two templates: forward + reverse)
# ============================================================================
def model_json(model_id, model_name, now):
"""Return the note-type entry for `col.models`.
Five fields and two templates. `Bedeutung` prompts for the meaning
given the code; `Kürzel` is the reverse. Modelling both directions
on one note type (rather than two single-template notes) is what
rule 4 asks for: one record, two cards. `req` records which field
each template needs so Anki never generates an empty card. The
technical deck reuses this same shape under its own `model_name`.
"""
fields = [
field_json("Code", 0),
field_json("Meaning", 1),
field_json("Kind", 2),
field_json("Form", 3),
field_json("Explanation", 4),
field_json("Example", 5),
]
extras = (
"{{#Explanation}}<div class=\"af-sh-explanation\">"
"{{Explanation}}</div>{{/Explanation}}\n"
"{{#Example}}<div class=\"af-sh-example\">{{Example}}</div>{{/Example}}"
)
front_meta = (
'<div class="af-sh-kind">{{Kind}}{{#Form}} · {{Form}}{{/Form}}</div>'
)
return {
"id": model_id,
"name": model_name,
"type": 0,
"mod": now,
"usn": -1,
"sortf": 0,
"did": None,
"tmpls": [
{
"name": "Bedeutung",
"ord": 0,
"qfmt": (
'<div class="af-sh">' + front_meta
+ '<div class="af-sh-code">{{Code}}</div>'
+ '<div class="af-sh-prompt">Bedeutung?</div></div>'
),
"afmt": (
"{{FrontSide}}\n<hr id=answer>\n"
'<div class="af-sh-meaning">{{Meaning}}</div>\n' + extras
),
"did": None,
"bqfmt": "",
"bafmt": "",
},
{
"name": "Kürzel",
"ord": 1,
"qfmt": (
'<div class="af-sh">' + front_meta
+ '<div class="af-sh-meaning">{{Meaning}}</div>'
+ '<div class="af-sh-prompt">Kürzel?</div></div>'
),
"afmt": (
"{{FrontSide}}\n<hr id=answer>\n"
'<div class="af-sh-code">{{Code}}</div>\n' + extras
),
"did": None,
"bqfmt": "",
"bafmt": "",
},
],
"flds": fields,
"css": CARD_CSS,
"latexPre": "",
"latexPost": "",
# Template 0 needs Code (field 0); template 1 needs Meaning (1).
"req": [[0, "all", [0]], [1, "all", [1]]],
"vers": [],
}
# ============================================================================
# SQLite collection writer (two cards per note)
# ============================================================================
def create_collection_db(db_path, deck_id, deck_name, model_id, model_name, notes, now):
"""Create the `collection.anki2` database for a glossary deck.
Same v11 schema as the sibling script, but each note fans out into
two cards (one per template `ord`). `due` increments across every
card so the deck has a stable introduction order. `deck_name` and
`model_name` are passed through so the technical deck can reuse this
with its own names.
"""
conn = sqlite3.connect(db_path)
try:
create_schema(conn)
_insert_collection_metadata(
conn, deck_id, deck_name, model_id, model_name, now,
)
due = 0
for note in notes:
conn.execute(
"""
INSERT INTO notes
(id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
note["note_id"],
note["guid"],
model_id,
now,
-1,
note["tags"],
note["fields"],
note["sort"],
checksum_sort_field(note["sort"]),
0,
"",
),
)
for ord_, card_id in enumerate(note["card_ids"]):
due += 1
conn.execute(
"""
INSERT INTO cards
(id, nid, did, ord, mod, usn, type, queue, due, ivl,
factor, reps, lapses, left, odue, odid, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
card_id,
note["note_id"],
deck_id,
ord_,
now,
-1,
0,
0,
due,
0,
0,
0,
0,
0,
0,
0,
0,
"",
),
)
conn.commit()
finally:
conn.close()
def _insert_collection_metadata(conn, deck_id, deck_name, model_id, model_name, now):
"""Write the single `col` row carrying the JSON config blobs.
A trimmed copy of the sibling script's metadata writer, differing
only in the model (two templates) and the single deck.
"""
conn.execute(
"""
INSERT INTO col
(id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
now,
now * 1000,
now * 1000,
11,
0,
0,
0,
json.dumps(collection_conf(deck_id), separators=(",", ":")),
json.dumps(
{str(model_id): model_json(model_id, model_name, now)},
separators=(",", ":"),
),
json.dumps(
{str(deck_id): deck_json(deck_id, deck_name, now)},
separators=(",", ":"),
),
json.dumps(default_deck_conf(now), separators=(",", ":")),
"{}",
),
)
# ============================================================================
# Card styling
# ============================================================================
CARD_CSS = """
.card {
font-family: Arial, sans-serif;
font-size: 18px;
line-height: 1.45;
color: #111;
background: #fff;
text-align: center;
}
.af-sh-kind {
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 11px;
color: #777;
margin-bottom: 0.6rem;
}
.af-sh-code {
font-size: 40px;
font-weight: bold;
font-family: "Courier New", monospace;
margin: 0.4rem 0;
color: #0b3a6b;
}
.af-sh-meaning {
font-size: 22px;
margin: 0.4rem 0;
}
.af-sh-prompt {
margin-top: 0.6rem;
font-size: 14px;
color: #999;
font-style: italic;
}
.af-sh-explanation {
margin-top: 1rem;
padding-top: 0.6rem;
border-top: 1px solid #ccc;
font-size: 15px;
color: #444;
}
.af-sh-example {
margin-top: 0.6rem;
font-size: 14px;
color: #555;
}
.af-sh-example code,
.af-sh-meaning code {
font-family: "Courier New", monospace;
background: #f0f0f0;
padding: 0 3px;
border-radius: 3px;
}
.nightMode .af-sh-code,
.card.nightMode .af-sh-code {
color: #8ab4f8;
}
.nightMode .af-sh-explanation,
.card.nightMode .af-sh-explanation {
border-top-color: #555;
color: #ccc;
}
.nightMode .af-sh-example code,
.nightMode .af-sh-meaning code,
.card.nightMode .af-sh-example code,
.card.nightMode .af-sh-meaning code {
background: #333;
}
"""
# ============================================================================
# Build orchestration
# ============================================================================
def resolve_build_epoch(data_dir, override_epoch):
"""Pick the package timestamp epoch.
Prefers `--epoch`, then the catalog manifest's `fetched_at` (so a
`make all` build stamps the shorthand deck consistently with the
catalog decks), and finally falls back to `DEFAULT_BUILD_EPOCH`.
The deck content is catalog-independent, so an absent data dir just
means "no manifest epoch available" rather than an error.
"""
if override_epoch is not None:
return int(override_epoch)
try:
_edition_dir, manifest, _catalog = load_latest_catalog(data_dir)
except AnkiBuildError:
return DEFAULT_BUILD_EPOCH
return build_epoch_from_manifest(manifest)
def write_glossary_deck(notes, deck_name, model_name, out_dir, build_epoch):
"""Write `notes` as one two-template `.apkg` and return its path.
Shared by both glossary decks (operating shorthand here, technical
abbreviations in `amateurfunk_technical.py`). The deck carries no
media — these cards are pure text. The output filename is derived
from the leaf of `deck_name` (`Amateurfunk::Foo` → `amateurfunk-foo`).
"""
slug = slugify(deck_name.split("::")[-1])
out_path = out_dir / f"amateurfunk-{slug}.apkg"
deck_id = stable_id("deck", deck_name)
model_id = stable_id("model", model_name)
tmp_dir = out_path.parent / f".{out_path.name}.tmp"
db_path = tmp_dir / "collection.anki2"
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
tmp_dir.mkdir(parents=True)
try:
create_collection_db(
db_path, deck_id, deck_name, model_id, model_name, notes, build_epoch,
)
write_apkg(out_path, db_path, {}, build_epoch=build_epoch)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
return out_path
def build_deck(shorthand_path, out_dir, data_dir, override_epoch=None):
"""Build the shorthand `.apkg` and return a small result dict."""
q_codes, abbreviations = load_shorthand(shorthand_path)
notes = build_notes(q_codes, abbreviations)
if not notes:
raise AnkiBuildError("shorthand database produced no notes")
build_epoch = resolve_build_epoch(data_dir, override_epoch)
out_path = write_glossary_deck(
notes, DECK_NAME, MODEL_NAME, out_dir, build_epoch,
)
return {
"path": out_path,
"deck": DECK_NAME,
"notes": len(notes),
"cards": sum(len(note["card_ids"]) for note in notes),
"q_codes": len(q_codes),
"abbreviations": len(abbreviations),
}
# ============================================================================
# Main entry point
# ============================================================================
def _parse_args(argv):
"""Build the argparse object and parse `argv`."""
parser = argparse.ArgumentParser(
description=(
"Build an Anki deck of amateur-radio Q-groups and operating "
"abbreviations from shorthand.json."
),
)
parser.add_argument(
"--shorthand",
type=Path,
default=DEFAULT_SHORTHAND_PATH,
help="curated source database (default: ./shorthand.json)",
)
parser.add_argument(
"--out",
type=Path,
default=DEFAULT_OUT_DIR,
help="output directory for the .apkg file (default: ./anki)",
)
parser.add_argument(
"--data",
type=Path,
default=DEFAULT_DATA_DIR,
help=(
"fetch output dir; only used to borrow a deterministic build "
"epoch from the catalog manifest (default: ./data)"
),
)
parser.add_argument(
"--epoch",
type=int,
default=None,
help="override the package timestamp epoch (default: from manifest)",
)
return parser.parse_args(argv)
def main(argv=None):
"""Top-level entry point. Returns an exit code; never raises."""
args = _parse_args(argv)
try:
result = build_deck(
args.shorthand,
args.out,
args.data,
override_epoch=args.epoch,
)
except AnkiBuildError as e:
print(f"error: {e}", file=sys.stderr)
return EXIT_ERROR
except Exception as e: # noqa: BLE001 (main() promises not to raise)
print(f"error: failed to build shorthand deck: {e}", file=sys.stderr)
return EXIT_ERROR
print(
f"wrote {result['path']} "
f"({result['notes']} notes, {result['cards']} cards: "
f"{result['q_codes']} Q-groups, {result['abbreviations']} abbreviations)"
)
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())