Files
Amateurfunk-Anki/amateurfunk_technical.py
T
2026-06-17 11:48:27 +02:00

256 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Build an Anki deck of technical and HAM abbreviations.
A third glossary deck, sibling to `amateurfunk_shorthand.py`. Where that
script covers *operating* shorthand (Q-groups, CQ, 73), this one covers
the *technical* vocabulary: modulation and modes (SSB, FM, CW), signal
domains (NF, HF, ZF), building blocks (VFO, PLL, AGC), components,
measurements (dB, SWR, PEP), propagation, digital modes, and the
organisations/regulations a candidate meets — the exam terms plus the
common HAM abbreviations beyond it (real knowledge, not just the test).
The card mechanics are identical to the operating deck — one note per
abbreviation with two templates (code → meaning and the reverse) — so
this script is deliberately thin: it loads and validates
`technical.json`, turns each entry into a note, and hands the rest to
the shared glossary machinery in `amateurfunk_shorthand`. Each entry
also carries a German `category` (Betriebsart, Bauteil, …) shown on the
card and used as a filter tag.
Stdlib only, like its siblings.
"""
import argparse
import json
import sys
from pathlib import Path
from amateurfunk_anki import AnkiBuildError, slugify
# The glossary deck machinery is shared with the operating-shorthand
# script: the two-template note type, the two-cards-per-note collection
# writer, the deck packager, the build-epoch resolver, and the per-entry
# schema validator. Only the data shape, the deck/model names, and the
# tag scheme are specific to the technical deck.
from amateurfunk_shorthand import (
make_note,
resolve_build_epoch,
validate_entry,
write_glossary_deck,
)
# ============================================================================
# Constants
# ============================================================================
# Curated source database (editorial content tracked in git).
DEFAULT_TECHNICAL_PATH = Path("technical.json")
# Catalog output dir, consulted only to borrow a deterministic build
# epoch from the manifest (the deck content is catalog-independent).
DEFAULT_DATA_DIR = Path("data")
# Shared output dir for every .apkg.
DEFAULT_OUT_DIR = Path("anki")
# Deck and note-type names. Both distinct from the operating deck so the
# two never merge on import, and the IDs live in their own namespace.
DECK_NAME = "Amateurfunk::Technische Abkürzungen"
MODEL_NAME = "Amateurfunk Technische Abkürzung"
# ID/GUID namespace for this deck (kept separate from the operating
# deck's "shorthand" namespace).
ID_NAMESPACE = "technical"
# Schema for one entry in `technical.json`. `category` is the German
# kind label shown on the card; it is required so nothing ships
# uncategorised.
TERM_REQUIRED = ("code", "category", "meaning")
TERM_OPTIONAL = ("exam", "explanation", "example", "tags")
EXIT_OK = 0
EXIT_ERROR = 1
# ============================================================================
# Loading and validating the curated database
# ============================================================================
def load_technical(path):
"""Return the validated list of term entries from a JSON file.
Mandatory file with a strict shape (same contract as the operating
deck): a missing or malformed file is a hard error, and unknown
fields are rejected so a typo can't silently drop content.
"""
try:
data = json.loads(path.read_text("utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise AnkiBuildError(f"could not read technical file {path}: {e}") from e
if not isinstance(data, dict):
raise AnkiBuildError(
f"technical file {path} must contain a JSON object at the top level"
)
terms = data.get("terms", [])
if not isinstance(terms, list):
raise AnkiBuildError(f"technical file {path}: 'terms' must be a list")
for entry in terms:
validate_entry(entry, TERM_REQUIRED, TERM_OPTIONAL, "terms")
_check_no_duplicate_codes(terms)
return terms
def _check_no_duplicate_codes(terms):
"""Raise if two terms share a code — that would overwrite a note.
Every stable ID and GUID is hashed from the code, so a duplicate
would silently clobber the first note on import.
"""
codes = [entry["code"] for entry in terms]
duplicates = sorted({code for code in codes if codes.count(code) > 1})
if duplicates:
raise AnkiBuildError(
"duplicate code(s) in technical database: " + ", ".join(duplicates)
)
# ============================================================================
# Note expansion
# ============================================================================
def build_notes(terms):
"""Turn each term into one two-card note (forward + reverse).
The German `category` becomes the note's `Kind` (shown on the card);
there is no usage form, so `Form` is empty. Identity is hashed from
the code under this deck's own ID namespace.
"""
notes = []
for entry in terms:
notes.append(
make_note(
code_display=entry["code"],
meaning=entry["meaning"],
kind=entry["category"],
form="",
explanation=entry.get("explanation"),
example=entry.get("example"),
tags=tags_for_term(entry),
namespace=ID_NAMESPACE,
)
)
return notes
def tags_for_term(entry):
"""Return the Anki tags field (space-padded) for one term.
A base `technik` tag, a `kategorie-<slug>` tag from the German
category, a `pruefung` tag when the term appears in the exam
catalog, and any extra editorial tags carried on the entry.
"""
tags = ["technik", f"kategorie-{slugify(entry['category'])}"]
if entry.get("exam"):
tags.append("pruefung")
tags.extend(slugify(tag) for tag in entry.get("tags", []))
return " " + " ".join(tags) + " "
# ============================================================================
# Build orchestration
# ============================================================================
def build_deck(technical_path, out_dir, data_dir, override_epoch=None):
"""Build the technical `.apkg` and return a small result dict."""
terms = load_technical(technical_path)
notes = build_notes(terms)
if not notes:
raise AnkiBuildError("technical 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),
"terms": len(terms),
}
# ============================================================================
# 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 technical and HAM "
"abbreviations from technical.json."
),
)
parser.add_argument(
"--technical",
type=Path,
default=DEFAULT_TECHNICAL_PATH,
help="curated source database (default: ./technical.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.technical,
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 technical deck: {e}", file=sys.stderr)
return EXIT_ERROR
print(
f"wrote {result['path']} "
f"({result['notes']} notes, {result['cards']} cards: "
f"{result['terms']} technical abbreviations)"
)
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())