Add glossary decks
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user