256 lines
8.6 KiB
Python
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())
|