#!/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-` 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())