#!/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 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", "") 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` → . 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 . """ return BACKTICK_SPAN_RE.sub( lambda m: f"{m.group(1)}", 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}}
" "{{Explanation}}
{{/Explanation}}\n" "{{#Example}}
{{Example}}
{{/Example}}" ) front_meta = ( '
{{Kind}}{{#Form}} · {{Form}}{{/Form}}
' ) return { "id": model_id, "name": model_name, "type": 0, "mod": now, "usn": -1, "sortf": 0, "did": None, "tmpls": [ { "name": "Bedeutung", "ord": 0, "qfmt": ( '
' + front_meta + '
{{Code}}
' + '
Bedeutung?
' ), "afmt": ( "{{FrontSide}}\n
\n" '
{{Meaning}}
\n' + extras ), "did": None, "bqfmt": "", "bafmt": "", }, { "name": "Kürzel", "ord": 1, "qfmt": ( '
' + front_meta + '
{{Meaning}}
' + '
Kürzel?
' ), "afmt": ( "{{FrontSide}}\n
\n" '
{{Code}}
\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, .card.nightMode .af-sh-example { color: #bbb; } .nightMode .af-sh-example code, .nightMode .af-sh-meaning code, .card.nightMode .af-sh-example code, .card.nightMode .af-sh-meaning code { background: #333; color: #eee; } """ # ============================================================================ # 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())