diff --git a/CLAUDE.md b/CLAUDE.md index 1e19ae2..7c9b484 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,12 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/ ├── amateurfunk-technische-kenntnisse-a.apkg ├── amateurfunk-betriebliche-kenntnisse.apkg └── amateurfunk-kenntnisse-von-vorschriften.apkg + +shorthand.json ──[Stage 2b: amateurfunk_shorthand.py]──► anki/ + └── amateurfunk-abkuerzungen-q-gruppen.apkg + +technical.json ──[Stage 2c: amateurfunk_technical.py]──► anki/ + └── amateurfunk-technische-abkuerzungen.apkg ``` ### Stage 1 — `amateurfunk_fetch.py` @@ -92,6 +98,45 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/ The Anki design decisions (shuffle seeding, deterministic build epoch, SVG dark-mode handling, schema choices) live in `DESIGN.md` §7. +### Stage 2b — `amateurfunk_shorthand.py` + +A sibling builder for a standalone reference deck of Q-groups and +operating abbreviations — the ones in the exam plus the most common +on-air shorthand the exam never covers (real operating knowledge, not +just the test). Content lives in the hand-curated `shorthand.json` +(editorial, tracked in git, like `explanations.json`); the +`references/Q-Codes.md` reference is where the exam-present codes were +catalogued. + +Each code is one Anki *note* with two card templates — forward +(code → meaning) and reverse (meaning → code) — so a single record +drives both directions. A Q-group means one thing as a statement +(`QSO`) and another as a question (`QSO?`), so each Q-group yields two +notes; plain abbreviations yield one. All IDs/GUIDs are hashed from the +displayed code form (stable re-import). The deck is catalog-independent +and fully deterministic; it only consults `data/` to borrow the +manifest build epoch when present. Low-level apkg/SQLite machinery is +imported from `amateurfunk_anki.py` so the two stay in lockstep. The +glossary machinery shared with Stage 2c (two-template note type, +two-cards-per-note writer, packager, build-epoch resolver, entry +validator) also lives here. + +### Stage 2c — `amateurfunk_technical.py` + +A third glossary deck, same card mechanics as Stage 2b (one note, +forward+reverse templates), for the *technical* vocabulary rather than +operating shorthand: modulation/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 (ITU, CEPT, EMV) — exam terms plus common HAM +abbreviations beyond the exam. Content lives in the curated +`technical.json`; each entry carries a German `category` (Betriebsart, +Bauteil, …) shown on the card and used as a `kategorie-*` tag. The +shared glossary machinery is imported from `amateurfunk_shorthand.py`; +this script only adds the data shape, the deck/model names, and the tag +scheme. IDs live in their own `technical` namespace so the two glossary +decks never collide on import. + ## Repo conventions - Python 3.11+, standard library only. No third-party dependencies diff --git a/Makefile b/Makefile index 10eafc8..fa1397e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: all fetch anki test clean +.PHONY: all fetch anki shorthand technical test clean -all: fetch anki +all: fetch anki shorthand technical fetch: python3 amateurfunk_fetch.py @@ -8,8 +8,14 @@ fetch: anki: python3 amateurfunk_anki.py +shorthand: + python3 amateurfunk_shorthand.py + +technical: + python3 amateurfunk_technical.py + test: - python3 -m unittest test_amateurfunk_fetch test_amateurfunk_anki + python3 -m unittest test_amateurfunk_fetch test_amateurfunk_anki test_amateurfunk_shorthand test_amateurfunk_technical clean: rm -rf data anki __pycache__ diff --git a/README.md b/README.md index 047ae90..9508866 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,28 @@ Bundesnetzagentur and turn it into Anki decks. ## Quick start ```sh -make # fetch + build (default) +make # fetch + build everything (default) make fetch # download + extract the catalog only → data/ -make anki # rebuild .apkg files from data/ → anki/ -make test # run both test suites +make anki # rebuild the five exam decks from data/ → anki/ +make shorthand # rebuild the Q-group / operating-abbreviation deck → anki/ +make technical # rebuild the technical / HAM-abbreviation deck → anki/ +make test # run all test suites make clean # remove data/ and anki/ ``` -Output: five `.apkg` files under `anki/`. Betriebliche and +Output: seven `.apkg` files under `anki/`. + +**Five exam decks** built from the catalog: Betriebliche and Vorschriften get one deck each (shared across all license classes); Technische is split per class into three decks (N / E / A) following the catalog's `class` field. A class-A candidate who wants every -Technische question imports all three Technische decks. Re-importing -a newer build preserves your review history. +Technische question imports all three Technische decks. + +**Two glossary decks** of radio shorthand — Q-groups and operating +abbreviations, and technical/HAM abbreviations — built from curated +data rather than the catalog (see [Glossary decks](#glossary-decks)). + +Re-importing a newer build preserves your review history. ## Exam sections @@ -49,6 +58,46 @@ a class-A candidate is responsible for everything. Filter inside Anki by deck, by the `klasse-N|E|A` tag, or by the `Number` field prefix. +## Glossary decks + +Two extra decks teach the radio shorthand a candidate actually needs — +the codes used in the exam **plus** the most common ones used on the +air that the exam never tests (real operating knowledge, not just the +test). They are built from hand-curated JSON, independent of the +catalog, so they build even without `make fetch`: + +| Deck | Source | Builder | +|-----------------------------------------------|------------------|----------------------------| +| `amateurfunk-abkuerzungen-q-gruppen.apkg` | `shorthand.json` | `amateurfunk_shorthand.py` | +| `amateurfunk-technische-abkuerzungen.apkg` | `technical.json` | `amateurfunk_technical.py` | + +- **Q-groups & operating abbreviations** — Q-codes (QRM, QSO, QSY…), + CW/voice shorthand (CQ, DE, 73, RST…), prosigns, and the + distress/urgency signals (MAYDAY, SOS…). +- **Technical & HAM abbreviations** — 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 (ITU, CEPT, EMV). + +Each code is a single Anki **note** with two cards: one prompts for the +meaning given the code, the reverse prompts for the code given the +meaning. A Q-group means one thing as a statement (`QSO`) and another +as a question (`QSO?`), so each becomes two notes (four cards). + +Filter inside Anki by tag: `pruefung` marks codes that appear in the +exam catalog; `q-gruppe` / `abkuerzung` and (technical deck) +`kategorie-*` mark the kind; `prosign` and `notsignal` mark prosigns +and the non-amateur distress signals. The `pruefung` flags and the +meanings were cross-checked against the BNetzA catalog — `pruefung` +means "this code's meaning is tested", not merely "the string appears +somewhere". + +> **⚠ Important: AI-generated content.** `shorthand.json` and +> `technical.json` are compiled with AI assistance. As with the +> explanations and references, verify anything that looks off against a +> primary source (the catalog, the ARRL/DARC Q-code lists, or the +> resources under [See also](#see-also)) before relying on it. + ## Exam question source The catalog is published by the Bundesnetzagentur (BNetzA), the German @@ -163,15 +212,20 @@ not part of the build, and are **not** bundled into the `.apkg` decks. - `references/Call-Signs.md` — call-sign patterns, suffixes, and country prefixes that appear in the catalog. - `references/Q-Codes.md` — Q-codes and operating shorthand used in the - questions. + questions. This reference is the catalogue the + [Q-group glossary deck](#glossary-decks) (`shorthand.json`) was built + from. ## More -- `CLAUDE.md` — project orientation, pipeline overview. +- `CLAUDE.md` — project orientation, pipeline overview (including the + two [glossary decks](#glossary-decks)). - `DESIGN.md` — source-discovery notes, JSON schema, per-stage design contracts. - `EXPLANATIONS.md` — schema + workflows for the explanations database. +- `shorthand.json` / `technical.json` — curated source data for the + two [glossary decks](#glossary-decks). ## License diff --git a/amateurfunk_shorthand.py b/amateurfunk_shorthand.py new file mode 100644 index 0000000..cd8e3f2 --- /dev/null +++ b/amateurfunk_shorthand.py @@ -0,0 +1,742 @@ +#!/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 code, +.nightMode .af-sh-meaning code, +.card.nightMode .af-sh-example code, +.card.nightMode .af-sh-meaning code { + background: #333; +} +""" + + +# ============================================================================ +# 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()) diff --git a/amateurfunk_technical.py b/amateurfunk_technical.py new file mode 100644 index 0000000..bdf8cd3 --- /dev/null +++ b/amateurfunk_technical.py @@ -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-` 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()) diff --git a/shorthand.json b/shorthand.json new file mode 100644 index 0000000..3ee29e9 --- /dev/null +++ b/shorthand.json @@ -0,0 +1,529 @@ +{ + "q_codes": [ + { + "code": "QRA", + "question": "What is the name (call sign) of your station?", + "statement": "The name of my station is ...", + "exam": false, + "explanation": "Asks for or gives the station's name or call sign." + }, + { + "code": "QRG", + "question": "Will you tell me my exact frequency?", + "statement": "Your exact frequency is ... kHz.", + "exam": false, + "explanation": "Used to ask for or report an exact operating frequency." + }, + { + "code": "QRH", + "question": "Does my frequency vary?", + "statement": "Your frequency varies.", + "exam": false, + "explanation": "Reports that a transmitter is drifting off its nominal frequency." + }, + { + "code": "QRK", + "question": "What is the readability of my signals?", + "statement": "The readability of your signals is ... (1 to 5).", + "exam": false, + "explanation": "Reports intelligibility on a 1 to 5 scale, the R in an RST report." + }, + { + "code": "QRL", + "question": "Are you busy? / Is this frequency in use?", + "statement": "I am busy; the frequency is in use, please do not interfere.", + "exam": false, + "explanation": "Sent as QRL? before transmitting to check whether a frequency is already occupied.", + "example": "DL1ABC: `QRL?` / DO2XYZ: `QRL, in QSO.`" + }, + { + "code": "QRM", + "question": "Are you being interfered with?", + "statement": "I am being interfered with; man-made interference.", + "exam": true, + "explanation": "Used to report man-made interference or ask whether the other station has interference.", + "example": "DL1ABC: `QRM?` / DO2XYZ: `QRM, pse repeat report.`" + }, + { + "code": "QRN", + "question": "Are you troubled by atmospheric noise?", + "statement": "I am troubled by atmospheric noise (static).", + "exam": true, + "explanation": "Used when static crashes or atmospheric noise make reception difficult.", + "example": "DL1ABC: `QRN here, ur report?` / DO2XYZ: `RST 55 despite QRN.`" + }, + { + "code": "QRO", + "question": "Shall I increase transmitter power?", + "statement": "Increase transmitter power; I am increasing power.", + "exam": true, + "explanation": "Used when more power may be needed, usually after trying normal operating improvements first.", + "example": "DL1ABC: `QRO?` / DO2XYZ: `PSE QRO, ur weak.`" + }, + { + "code": "QRP", + "question": "Shall I decrease transmitter power?", + "statement": "Decrease transmitter power; low-power operation.", + "exam": true, + "explanation": "Used to request or announce reduced transmitter power; also describes low-power operation.", + "example": "DL1ABC: `PSE QRP.` / DO2XYZ: `QRP 5 W now.`" + }, + { + "code": "QRQ", + "question": "Shall I send faster?", + "statement": "Send faster (... words per minute).", + "exam": false, + "explanation": "Requests a faster Morse sending speed." + }, + { + "code": "QRS", + "question": "Shall I send more slowly?", + "statement": "Send more slowly.", + "exam": false, + "explanation": "Requests a slower Morse sending speed; common courtesy to slower operators." + }, + { + "code": "QRT", + "question": "Shall I stop sending?", + "statement": "Stop sending; I am closing down.", + "exam": true, + "explanation": "Used to close down transmission or ask another station to stop transmitting.", + "example": "DL1ABC: `I must QRT.` / DO2XYZ: `QSL, 73.`" + }, + { + "code": "QRU", + "question": "Have you anything for me?", + "statement": "I have nothing for you.", + "exam": false, + "explanation": "Asks whether there is traffic to pass, or reports that there is none." + }, + { + "code": "QRV", + "question": "Are you ready?", + "statement": "I am ready.", + "exam": true, + "explanation": "Used to ask if a station is ready, or to say that a station is available for traffic.", + "example": "DL1ABC: `QRV?` / DO2XYZ: `QRV, go ahead.`" + }, + { + "code": "QRX", + "question": "When will you call me again? / Shall I wait?", + "statement": "I will call you again at ...; please wait (stand by).", + "exam": true, + "explanation": "Used to ask for a later call or to tell the other station to wait.", + "example": "DL1ABC: `QRX 5 min.` / DO2XYZ: `QSL, standing by.`" + }, + { + "code": "QRZ", + "question": "Who is calling me?", + "statement": "You are being called by ...; in pile-ups also a request for further callers.", + "exam": true, + "explanation": "Used when a calling station was not copied, or by a wanted station to invite the next caller.", + "example": "DL1ABC: `QRZ?` / DO2XYZ: `DO2XYZ calling.`" + }, + { + "code": "QSA", + "question": "What is the strength of my signals?", + "statement": "The strength of your signals is ... (1 to 5).", + "exam": false, + "explanation": "Reports signal strength on a 1 to 5 scale, related to the S in an RST report." + }, + { + "code": "QSB", + "question": "Is the strength of my signal fading?", + "statement": "Your signal strength is fading or fluctuating.", + "exam": true, + "explanation": "Used when signal strength rises and falls because of propagation.", + "example": "DL1ABC: `QSB?` / DO2XYZ: `Yes, strong QSB, now 55.`" + }, + { + "code": "QSK", + "question": "Can you hear me between your signals?", + "statement": "I can hear you between my signals; you may break in (full break-in).", + "exam": false, + "explanation": "Describes full break-in CW, where the operator hears the band between his own dots and dashes." + }, + { + "code": "QSL", + "question": "Can you acknowledge receipt?", + "statement": "I acknowledge receipt; also a confirmation card or electronic confirmation for a QSO.", + "exam": true, + "explanation": "Used to confirm that information was received, or later for contact confirmation.", + "example": "DL1ABC: `My QTH Berlin, QSL?` / DO2XYZ: `QSL, Berlin copied.`" + }, + { + "code": "QSO", + "question": "Can you communicate directly with ...?", + "statement": "I can communicate directly with ...; commonly a radio contact.", + "exam": true, + "explanation": "Used for the radio contact itself, or when asking whether direct contact is possible.", + "example": "DL1ABC: `Tnx for QSO.` / DO2XYZ: `Tnx also, 73.`" + }, + { + "code": "QSP", + "question": "Will you relay to ...?", + "statement": "I will relay to ...", + "exam": false, + "explanation": "Asks a station to pass traffic on to a third station that cannot be reached directly." + }, + { + "code": "QSY", + "question": "Shall I change to another frequency?", + "statement": "Change frequency; I am changing frequency.", + "exam": true, + "explanation": "Used to move a contact away from a calling frequency or crowded channel.", + "example": "DL1ABC: `PSE QSY 145.525.` / DO2XYZ: `QSL, QSY now.`" + }, + { + "code": "QTC", + "question": "How many messages have you to send?", + "statement": "I have ... messages to send.", + "exam": false, + "explanation": "Used in traffic handling and contests to announce a count of formal messages." + }, + { + "code": "QTH", + "question": "What is your position or location?", + "statement": "My position or location is ...", + "exam": true, + "explanation": "Used to ask for or give the station location, often city, locator, or portable position.", + "example": "DL1ABC: `QTH?` / DO2XYZ: `QTH Munich, JN58.`" + }, + { + "code": "QTR", + "question": "What is the correct time?", + "statement": "The correct time is ...", + "exam": false, + "explanation": "Asks for or gives the time, usually in UTC." + } + ], + "abbreviations": [ + { + "code": "73", + "meaning": "Best regards (friendly sign-off).", + "exam": false, + "explanation": "The standard friendly close of a contact. Always singular: 'best 73', never '73s'.", + "example": "DL1ABC: `tnx QSO, 73!` / DO2XYZ: `73 es cuagn.`" + }, + { + "code": "88", + "meaning": "Love and kisses (affectionate sign-off).", + "exam": false, + "explanation": "An affectionate close, traditionally used towards a partner or in friendly mixed company." + }, + { + "code": "AGN", + "meaning": "Again / please repeat.", + "exam": false, + "explanation": "Asks the other station to repeat something that was missed." + }, + { + "code": "ANT", + "meaning": "Antenna.", + "exam": false + }, + { + "code": "AR", + "meaning": "End of message (prosign, sent as one run-together character).", + "exam": false, + "tags": ["prosign"], + "explanation": "Marks the end of a complete transmission before the callsign exchange and K." + }, + { + "code": "AS", + "meaning": "Wait / stand by (prosign).", + "exam": false, + "tags": ["prosign"], + "explanation": "Asks the other station to wait briefly without ending the transmission." + }, + { + "code": "BK", + "meaning": "Break — interrupt or quickly hand over a running transmission.", + "exam": true, + "explanation": "Used in telegraphy to interrupt or quickly pass transmission without a formal over.", + "example": "DL1ABC: `... antenna is BK` / DO2XYZ: `BK, pse repeat power.`" + }, + { + "code": "BT", + "meaning": "Separator between parts of a message (prosign, written =).", + "exam": false, + "tags": ["prosign"], + "explanation": "Run-together dah-di-di-di-dah, used like a paragraph break inside a message." + }, + { + "code": "BTU", + "meaning": "Back to you.", + "exam": false + }, + { + "code": "CFM", + "meaning": "Confirm / I confirm.", + "exam": false, + "explanation": "Confirms information such as a report, locator, or QSL." + }, + { + "code": "CL", + "meaning": "Closing down / clear (going off the air).", + "exam": false + }, + { + "code": "CQ", + "meaning": "General call to any station.", + "exam": true, + "explanation": "Used to start a contact when no specific station is being called. A target narrows it, e.g. CQ DX or CQ DL.", + "example": "DL1ABC: `CQ CQ CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k`" + }, + { + "code": "CUL", + "meaning": "See you later.", + "exam": false + }, + { + "code": "DE", + "meaning": "From / this is (precedes the sending station's call sign).", + "exam": true, + "explanation": "Used between the called station and own call sign, especially in CW.", + "example": "DL1ABC: `CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k`" + }, + { + "code": "DX", + "meaning": "Long distance / distant station.", + "exam": true, + "explanation": "Used to seek or describe long-distance contacts. The required distance depends on band: intercontinental on HF, several hundred km on VHF/UHF.", + "example": "DL1ABC: `CQ DX de DL1ABC k` / PY2XYZ: `DL1ABC de PY2XYZ k`" + }, + { + "code": "ES", + "meaning": "And.", + "exam": false, + "explanation": "Telegraphy shorthand for 'and', from the Morse ampersand.", + "example": "DL1ABC: `73 es gud DX`" + }, + { + "code": "FB", + "meaning": "Fine business — excellent / understood well.", + "exam": false, + "explanation": "A general expression of approval: good, excellent, all copied." + }, + { + "code": "FD", + "meaning": "Field Day (portable contest operation).", + "exam": true, + "explanation": "Used during Field Day contest operation to attract participating stations.", + "example": "DL1ABC/P: `CQ FD de DL1ABC/P TEST`" + }, + { + "code": "GA", + "meaning": "Good afternoon / go ahead.", + "exam": false + }, + { + "code": "GE", + "meaning": "Good evening.", + "exam": false + }, + { + "code": "GM", + "meaning": "Good morning.", + "exam": false + }, + { + "code": "GN", + "meaning": "Good night.", + "exam": false + }, + { + "code": "GUD", + "meaning": "Good.", + "exam": false + }, + { + "code": "HI", + "meaning": "Laughter (the Morse 'smiley').", + "exam": false, + "explanation": "Written representation of laughter in telegraphy; its dot pattern sounds like a chuckle." + }, + { + "code": "HR", + "meaning": "Here / hear.", + "exam": false + }, + { + "code": "HW", + "meaning": "How? (e.g. HW? = how do you copy me?).", + "exam": false + }, + { + "code": "K", + "meaning": "Invitation to transmit — go ahead / over.", + "exam": true, + "explanation": "Used at the end of a CW transmission to invite the other station to reply.", + "example": "DL1ABC: `DO2XYZ de DL1ABC k`" + }, + { + "code": "KN", + "meaning": "Go ahead, named station only (no other stations break in).", + "exam": false, + "tags": ["prosign"], + "explanation": "Like K, but invites only the specific station addressed to reply." + }, + { + "code": "MNI", + "meaning": "Many.", + "exam": false + }, + { + "code": "NR", + "meaning": "Number / near.", + "exam": false + }, + { + "code": "OB", + "meaning": "Old boy (familiar form of address).", + "exam": false + }, + { + "code": "OM", + "meaning": "Old man — any male operator.", + "exam": false, + "explanation": "Friendly address for a male amateur, regardless of age." + }, + { + "code": "OP", + "meaning": "Operator / the operator's name.", + "exam": false + }, + { + "code": "PSE", + "meaning": "Please.", + "exam": true, + "explanation": "Used as a polite request marker, e.g. PSE QRP or PSE QSY.", + "example": "DL1ABC: `PSE QSY 145.525` / DO2XYZ: `QSL, QSY.`" + }, + { + "code": "PWR", + "meaning": "Power (transmitter output).", + "exam": false + }, + { + "code": "R", + "meaning": "Received — everything before it was copied correctly.", + "exam": true, + "explanation": "Sent at the start of a transmission to acknowledge full receipt of the previous one.", + "example": "DL1ABC: `R tnx report, ur RST 579` / DO2XYZ: `QSL, 73.`" + }, + { + "code": "RIG", + "meaning": "Station equipment / transceiver.", + "exam": false + }, + { + "code": "RPT", + "meaning": "Repeat / report.", + "exam": false + }, + { + "code": "RST", + "meaning": "Signal report: Readability, Strength, Tone.", + "exam": true, + "explanation": "Three-figure reception report. In SSB only R and S are normally given (Tone applies to CW).", + "example": "DL1ABC: `ur RST 599` / DO2XYZ: `tnx, ur 579.`" + }, + { + "code": "RX", + "meaning": "Receiver.", + "exam": true + }, + { + "code": "SK", + "meaning": "End of contact (prosign); also 'silent key', a deceased radio amateur.", + "exam": false, + "tags": ["prosign"], + "explanation": "Sent run-together to close out a complete contact; outside operating, a Silent Key is a ham who has died." + }, + { + "code": "SRI", + "meaning": "Sorry.", + "exam": false + }, + { + "code": "TEST", + "meaning": "Contest call indicator.", + "exam": true, + "explanation": "Used in contest calls to show the call is for contest contacts.", + "example": "DL1ABC: `CQ TEST de DL1ABC` / DO2XYZ: `DL1ABC de DO2XYZ 59 012`" + }, + { + "code": "TNX", + "meaning": "Thanks (also written TKS).", + "exam": false + }, + { + "code": "TU", + "meaning": "Thank you.", + "exam": false + }, + { + "code": "TX", + "meaning": "Transmitter.", + "exam": true + }, + { + "code": "UR", + "meaning": "Your / you're.", + "exam": false + }, + { + "code": "VY", + "meaning": "Very.", + "exam": false + }, + { + "code": "WX", + "meaning": "Weather.", + "exam": false + }, + { + "code": "XYL", + "meaning": "Wife (ex-young-lady).", + "exam": false + }, + { + "code": "YL", + "meaning": "Young lady — any female operator.", + "exam": false + }, + { + "code": "MAYDAY", + "meaning": "International distress signal (spoken). Must not be used in amateur radio traffic.", + "exam": true, + "tags": ["notsignal"], + "explanation": "Used in maritime or aeronautical distress traffic, not in normal amateur radio traffic.", + "example": "Vessel: `MAYDAY MAYDAY MAYDAY, this is DABC.`" + }, + { + "code": "SOS", + "meaning": "International distress signal (Morse). Must not be used in amateur radio traffic.", + "exam": true, + "tags": ["notsignal"], + "explanation": "Used as an international distress signal, especially in Morse contexts outside amateur traffic.", + "example": "Station: `SOS SOS SOS de DABC`" + }, + { + "code": "PAN PAN", + "meaning": "International urgency signal. Must not be used in amateur radio traffic.", + "exam": true, + "tags": ["notsignal"], + "explanation": "Used outside amateur radio for urgent situations that are serious but not yet distress.", + "example": "Vessel: `PAN PAN, this is DABC, engine failure.`" + }, + { + "code": "SÉCURITÉ", + "meaning": "International safety signal. Must not be used in amateur radio traffic.", + "exam": true, + "tags": ["notsignal"], + "explanation": "Used outside amateur radio for safety information such as navigation or weather warnings.", + "example": "Coast station: `SECURITE, weather warning.`" + } + ] +} diff --git a/technical.json b/technical.json new file mode 100644 index 0000000..b6aa6a1 --- /dev/null +++ b/technical.json @@ -0,0 +1,616 @@ +{ + "terms": [ + { + "code": "AM", + "category": "Betriebsart", + "meaning": "Amplitudenmodulation — amplitude modulation.", + "exam": true, + "explanation": "The carrier amplitude follows the audio signal. Both sidebands and the carrier are transmitted, so AM needs more bandwidth and power than SSB." + }, + { + "code": "FM", + "category": "Betriebsart", + "meaning": "Frequenzmodulation — frequency modulation.", + "exam": true, + "explanation": "The carrier frequency follows the audio signal at constant amplitude. Robust against amplitude noise; the usual mode for VHF/UHF (UKW) voice and repeaters." + }, + { + "code": "PM", + "category": "Betriebsart", + "meaning": "Phasenmodulation — phase modulation.", + "exam": true, + "explanation": "The carrier phase follows the signal; closely related to FM and often used to generate it." + }, + { + "code": "DSB", + "category": "Betriebsart", + "meaning": "Doppelseitenbandmodulation — double-sideband modulation.", + "exam": true, + "explanation": "Both sidebands are transmitted; with the carrier suppressed it is DSB-SC, the starting point for generating SSB." + }, + { + "code": "SSB", + "category": "Betriebsart", + "meaning": "Einseitenbandmodulation — single sideband.", + "exam": true, + "explanation": "Only one sideband is sent and the carrier is suppressed, making it the most bandwidth- and power-efficient analogue voice mode on HF." + }, + { + "code": "USB", + "category": "Betriebsart", + "meaning": "oberes Seitenband — upper sideband.", + "exam": true, + "explanation": "SSB using the sideband above the suppressed carrier. Convention on HF above 10 MHz and on VHF/UHF." + }, + { + "code": "LSB", + "category": "Betriebsart", + "meaning": "unteres Seitenband — lower sideband.", + "exam": true, + "explanation": "SSB using the sideband below the suppressed carrier. Convention on the HF bands below 10 MHz (160/80/40 m)." + }, + { + "code": "CW", + "category": "Betriebsart", + "meaning": "Continuous Wave — Morsetelegrafie (A1A).", + "exam": true, + "explanation": "On/off keying of an unmodulated carrier. Very narrow bandwidth and excellent weak-signal readability." + }, + { + "code": "FSK", + "category": "Betriebsart", + "meaning": "Frequency Shift Keying — Frequenzumtastung.", + "exam": true, + "explanation": "Digital keying that shifts the carrier between discrete frequencies; the basis of RTTY." + }, + { + "code": "PSK", + "category": "Betriebsart", + "meaning": "Phase Shift Keying — Phasenumtastung.", + "exam": true, + "explanation": "Digital keying that encodes data in carrier phase shifts." + }, + { + "code": "ASK", + "category": "Betriebsart", + "meaning": "Amplitude Shift Keying — Amplitudenumtastung.", + "exam": true + }, + { + "code": "QAM", + "category": "Betriebsart", + "meaning": "Quadraturamplitudenmodulation — quadrature amplitude modulation.", + "exam": true, + "explanation": "Combines amplitude and phase modulation on two quadrature carriers to carry several bits per symbol." + }, + { + "code": "RTTY", + "category": "Betriebsart", + "meaning": "Radioteletype — Funkfernschreiben.", + "exam": true, + "explanation": "Classic FSK teleprinter mode using the 5-bit Baudot code." + }, + { + "code": "PSK31", + "category": "Betriebsart", + "meaning": "Phase-Shift Keying, 31 Baud — narrowband keyboard digital mode.", + "exam": true + }, + { + "code": "FT8", + "category": "Betriebsart", + "meaning": "digitale Schwachsignal-Betriebsart (WSJT-X) — weak-signal digital mode.", + "exam": true, + "explanation": "Highly sensitive mode with fixed 15-second transmit slots; decodes signals well below the noise floor." + }, + { + "code": "WSPR", + "category": "Betriebsart", + "meaning": "Weak Signal Propagation Reporter — Bakenbetrieb zur Ausbreitungsforschung.", + "exam": true, + "explanation": "Very low-power beacon mode whose reception reports are pooled to map propagation." + }, + { + "code": "SSTV", + "category": "Betriebsart", + "meaning": "Slow Scan Television — Schmalband-Bildübertragung.", + "exam": true, + "explanation": "Sends still pictures slowly enough to fit a voice-bandwidth channel." + }, + { + "code": "ATV", + "category": "Betriebsart", + "meaning": "Amateurfernsehen — (fast-scan) amateur television.", + "exam": true, + "explanation": "Real-time video, typically on the higher bands because of its wide bandwidth." + }, + { + "code": "NBFM", + "category": "Betriebsart", + "meaning": "Schmalband-FM — narrowband FM.", + "exam": true, + "explanation": "FM with reduced deviation (e.g. a 12.5 kHz channel), the usual form for amateur FM voice." + }, + + { + "code": "MF", + "category": "Band", + "meaning": "Mittelwelle — medium frequency, 300 kHz to 3 MHz.", + "exam": true + }, + { + "code": "KW", + "category": "Band", + "meaning": "Kurzwelle — shortwave / HF, 3 to 30 MHz.", + "exam": true, + "explanation": "The classic long-distance amateur range; relies on ionospheric (skywave) propagation." + }, + { + "code": "UKW", + "category": "Band", + "meaning": "Ultrakurzwelle — VHF, 30 to 300 MHz.", + "exam": true, + "explanation": "Mostly line-of-sight propagation; home of FM voice and repeaters. In the BNetzA band names UKW is exactly VHF; 300 MHz to 3 GHz is UHF / Dezimeterwelle. Colloquially 'UKW' is sometimes stretched to mean VHF and up, but that is not the exam definition." + }, + { + "code": "VHF", + "category": "Band", + "meaning": "Very High Frequency — 30 to 300 MHz (UKW).", + "exam": true + }, + { + "code": "UHF", + "category": "Band", + "meaning": "Ultra High Frequency — 300 MHz to 3 GHz.", + "exam": true + }, + { + "code": "SHF", + "category": "Band", + "meaning": "Super High Frequency — 3 to 30 GHz (Mikrowellen).", + "exam": true + }, + { + "code": "EHF", + "category": "Band", + "meaning": "Extremely High Frequency — 30 to 300 GHz.", + "exam": false + }, + + { + "code": "NF", + "category": "Signal & Frequenz", + "meaning": "Niederfrequenz — audio frequency (about 16 Hz to 20 kHz).", + "exam": true, + "explanation": "The baseband audio signal before modulation or after demodulation." + }, + { + "code": "HF", + "category": "Signal & Frequenz", + "meaning": "Hochfrequenz — radio frequency (RF), above the audio range.", + "exam": true, + "explanation": "In German usage HF denotes RF signals in general, not specifically the 3 to 30 MHz band (that band is called KW / Kurzwelle)." + }, + { + "code": "ZF", + "category": "Signal & Frequenz", + "meaning": "Zwischenfrequenz — intermediate frequency (IF).", + "exam": true, + "explanation": "The fixed frequency a superheterodyne receiver mixes the signal to, where most selective amplification and filtering happens." + }, + { + "code": "IF", + "category": "Signal & Frequenz", + "meaning": "Intermediate Frequency — englische Bezeichnung für die Zwischenfrequenz (ZF).", + "exam": false + }, + { + "code": "RF", + "category": "Signal & Frequenz", + "meaning": "Radio Frequency — englische Bezeichnung für Hochfrequenz (HF).", + "exam": false + }, + + { + "code": "VFO", + "category": "Baugruppe", + "meaning": "variabler Oszillator — variable frequency oscillator.", + "exam": true, + "explanation": "The tunable oscillator that sets the operating frequency of a transmitter or receiver." + }, + { + "code": "VCO", + "category": "Baugruppe", + "meaning": "spannungsgesteuerter Oszillator — voltage-controlled oscillator.", + "exam": true, + "explanation": "An oscillator whose frequency follows a control voltage; the tunable element inside a PLL." + }, + { + "code": "PLL", + "category": "Baugruppe", + "meaning": "Phasenregelschleife — phase-locked loop.", + "exam": true, + "explanation": "Locks a VCO to a stable reference by comparing phase, forming the basis of modern frequency synthesizers." + }, + { + "code": "DDS", + "category": "Baugruppe", + "meaning": "direkte digitale Synthese — direct digital synthesis.", + "exam": true, + "explanation": "Generates a signal numerically from a clock and a phase accumulator, giving fine, fast frequency steps." + }, + { + "code": "BFO", + "category": "Baugruppe", + "meaning": "Schwebungsoszillator — beat frequency oscillator.", + "exam": true, + "explanation": "Reinjects a carrier at the product detector so CW and SSB become audible." + }, + { + "code": "LO", + "category": "Baugruppe", + "meaning": "Lokaloszillator — local (mixing) oscillator.", + "exam": false + }, + { + "code": "AGC", + "category": "Baugruppe", + "meaning": "automatische Verstärkungsregelung — automatic gain control.", + "exam": true, + "explanation": "Holds the receiver output roughly constant across strong and weak signals." + }, + { + "code": "ALC", + "category": "Baugruppe", + "meaning": "automatische Pegelregelung — automatic level control.", + "exam": true, + "explanation": "Limits transmitter drive to prevent overdrive and splatter." + }, + { + "code": "PA", + "category": "Baugruppe", + "meaning": "Endstufe / Leistungsverstärker — power amplifier.", + "exam": true + }, + { + "code": "LNA", + "category": "Baugruppe", + "meaning": "rauscharmer Vorverstärker — low-noise amplifier.", + "exam": true, + "explanation": "A low-noise preamplifier, often at the antenna, to improve weak-signal reception on VHF and up." + }, + { + "code": "ATU", + "category": "Baugruppe", + "meaning": "Antennenanpassgerät — antenna tuning unit.", + "exam": false, + "explanation": "Matches the feed line to the transmitter so the PA sees a low SWR; it does not 'tune' the antenna itself." + }, + { + "code": "TRX", + "category": "Baugruppe", + "meaning": "Transceiver — combined transmitter and receiver.", + "exam": true + }, + { + "code": "Balun", + "category": "Baugruppe", + "meaning": "Symmetrierglied — balanced-to-unbalanced transformer.", + "exam": true, + "explanation": "Couples a balanced antenna (e.g. a dipole) to an unbalanced coax feed, suppressing currents on the outside of the shield." + }, + + { + "code": "Diode", + "category": "Bauteil", + "meaning": "Diode — conducts current in one direction only.", + "exam": true + }, + { + "code": "LED", + "category": "Bauteil", + "meaning": "Leuchtdiode — light-emitting diode.", + "exam": true + }, + { + "code": "FET", + "category": "Bauteil", + "meaning": "Feldeffekttransistor — field-effect transistor.", + "exam": true, + "explanation": "A voltage-controlled transistor with very high input impedance." + }, + { + "code": "MOSFET", + "category": "Bauteil", + "meaning": "Metall-Oxid-Halbleiter-FET — metal-oxide-semiconductor FET.", + "exam": true, + "explanation": "An insulated-gate FET; common in switching and RF power stages." + }, + { + "code": "BJT", + "category": "Bauteil", + "meaning": "Bipolartransistor — bipolar junction transistor.", + "exam": false + }, + { + "code": "IC", + "category": "Bauteil", + "meaning": "integrierter Schaltkreis — integrated circuit.", + "exam": false + }, + { + "code": "NTC", + "category": "Bauteil", + "meaning": "Heißleiter — NTC thermistor (negative temperature coefficient).", + "exam": true, + "explanation": "Its resistance falls as temperature rises; used for temperature sensing and inrush limiting." + }, + { + "code": "PTC", + "category": "Bauteil", + "meaning": "Kaltleiter — PTC thermistor (positive temperature coefficient).", + "exam": true, + "explanation": "Its resistance rises as temperature rises; used as a resettable fuse and for protection." + }, + { + "code": "LDR", + "category": "Bauteil", + "meaning": "Fotowiderstand — light-dependent resistor.", + "exam": true + }, + { + "code": "VDR", + "category": "Bauteil", + "meaning": "spannungsabhängiger Widerstand (Varistor) — voltage-dependent resistor.", + "exam": true, + "explanation": "Resistance drops sharply above a threshold voltage; clamps surges and protects against overvoltage." + }, + + { + "code": "dB", + "category": "Messgröße & Leistung", + "meaning": "Dezibel — logarithmic ratio of two powers (10·log₁₀).", + "exam": true, + "explanation": "+3 dB is roughly double the power, +10 dB ten times. As a power ratio it is relative, not an absolute level." + }, + { + "code": "dBi", + "category": "Messgröße & Leistung", + "meaning": "Gewinn über Isotropstrahler — gain relative to an isotropic radiator.", + "exam": true + }, + { + "code": "dBd", + "category": "Messgröße & Leistung", + "meaning": "Gewinn über Dipol — gain relative to a half-wave dipole.", + "exam": true, + "explanation": "0 dBd = 2.15 dBi; the dipole already has 2.15 dBi of gain over isotropic." + }, + { + "code": "dBm", + "category": "Messgröße & Leistung", + "meaning": "Pegel bezogen auf 1 mW — power level referenced to 1 milliwatt.", + "exam": true, + "explanation": "An absolute level: 0 dBm = 1 mW, 30 dBm = 1 W." + }, + { + "code": "dBW", + "category": "Messgröße & Leistung", + "meaning": "Pegel bezogen auf 1 W — power level referenced to 1 watt.", + "exam": true + }, + { + "code": "SWR", + "category": "Messgröße & Leistung", + "meaning": "Stehwellenverhältnis — standing wave ratio.", + "exam": true, + "explanation": "Ratio of the standing wave's maximum to minimum on the feed line; 1:1 is a perfect match, higher values mean more reflected power." + }, + { + "code": "VSWR", + "category": "Messgröße & Leistung", + "meaning": "Spannungs-Stehwellenverhältnis — voltage standing wave ratio.", + "exam": false + }, + { + "code": "ERP", + "category": "Messgröße & Leistung", + "meaning": "effektive Strahlungsleistung — effective radiated power.", + "exam": true, + "explanation": "Power referenced to a half-wave dipole: transmitter power minus losses, times the antenna gain in dBd." + }, + { + "code": "EIRP", + "category": "Messgröße & Leistung", + "meaning": "äquivalente isotrope Strahlungsleistung — equivalent isotropically radiated power.", + "exam": true, + "explanation": "Like ERP but referenced to an isotropic radiator; EIRP = ERP + 2.15 dB." + }, + { + "code": "PEP", + "category": "Messgröße & Leistung", + "meaning": "Spitzenleistung — peak envelope power.", + "exam": true, + "explanation": "The average power over one RF cycle at the peak of the modulation envelope; the standard rating for SSB transmitters." + }, + { + "code": "RMS", + "category": "Messgröße & Leistung", + "meaning": "Effektivwert — root mean square.", + "exam": true, + "explanation": "The equivalent DC value that produces the same heating; for a sine wave it is the peak divided by √2." + }, + { + "code": "Q", + "category": "Messgröße & Leistung", + "meaning": "Güte — quality factor of a resonant circuit.", + "exam": true, + "explanation": "Ratio of stored to dissipated energy; a higher Q means a narrower, sharper resonance." + }, + { + "code": "SNR", + "category": "Messgröße & Leistung", + "meaning": "Signal-Rausch-Verhältnis — signal-to-noise ratio.", + "exam": true + }, + + { + "code": "AC", + "category": "Strom & Spannung", + "meaning": "Wechselstrom / Wechselspannung — alternating current.", + "exam": true + }, + { + "code": "DC", + "category": "Strom & Spannung", + "meaning": "Gleichstrom / Gleichspannung — direct current.", + "exam": true + }, + + { + "code": "MUF", + "category": "Ausbreitung", + "meaning": "höchste brauchbare Frequenz — maximum usable frequency.", + "exam": true, + "explanation": "The highest frequency the ionosphere still reflects back for a given path; above it the signal escapes into space." + }, + { + "code": "LUF", + "category": "Ausbreitung", + "meaning": "niedrigste brauchbare Frequenz — lowest usable frequency.", + "exam": true, + "explanation": "Below it absorption (mainly in the D layer) makes the path unusable." + }, + { + "code": "NVIS", + "category": "Ausbreitung", + "meaning": "Steilstrahlung — near-vertical incidence skywave.", + "exam": true, + "explanation": "Near-vertical HF radiation that comes back down over a wide area, giving gap-free regional coverage out to a few hundred km." + }, + + { + "code": "DMR", + "category": "Digital & Daten", + "meaning": "Digital Mobile Radio — digitale Sprach- und Datenbetriebsart.", + "exam": true, + "explanation": "TDMA digital voice standard widely used on amateur repeaters and networks." + }, + { + "code": "D-STAR", + "category": "Digital & Daten", + "meaning": "digitale Sprach- und Datenbetriebsart (ICOM/JARL).", + "exam": true + }, + { + "code": "C4FM", + "category": "Digital & Daten", + "meaning": "Continuous 4-level FM — Yaesu System Fusion digital voice.", + "exam": true + }, + { + "code": "APRS", + "category": "Digital & Daten", + "meaning": "Automatic Packet Reporting System — Positions- und Telemetriedaten über Packet.", + "exam": false + }, + { + "code": "TNC", + "category": "Digital & Daten", + "meaning": "Terminal Node Controller — Packet-Radio-Modem.", + "exam": false + }, + { + "code": "AX.25", + "category": "Digital & Daten", + "meaning": "Amateurfunk-Datenprotokoll — amateur packet-radio link protocol.", + "exam": false + }, + { + "code": "CTCSS", + "category": "Digital & Daten", + "meaning": "Continuous Tone-Coded Squelch System — subaudible tone squelch.", + "exam": false, + "explanation": "A low-frequency tone below the audio that opens a repeater's or receiver's squelch only for stations sending the matching tone." + }, + { + "code": "DTMF", + "category": "Digital & Daten", + "meaning": "Dual-Tone Multi-Frequency — Mehrfrequenzwahlverfahren.", + "exam": false + }, + { + "code": "DCS", + "category": "Digital & Daten", + "meaning": "Digital-Coded Squelch — digitale Rauschsperrencodierung.", + "exam": false + }, + { + "code": "PTT", + "category": "Digital & Daten", + "meaning": "Push To Talk — Sendetaste.", + "exam": true + }, + { + "code": "SDR", + "category": "Digital & Daten", + "meaning": "Software Defined Radio — softwaredefiniertes Funkgerät.", + "exam": true + }, + { + "code": "RIT", + "category": "Digital & Daten", + "meaning": "Receiver Incremental Tuning — Empfänger-Feinverstimmung.", + "exam": true + }, + + { + "code": "ITU", + "category": "Organisation & Vorschrift", + "meaning": "Internationale Fernmeldeunion — International Telecommunication Union.", + "exam": true, + "explanation": "UN agency that allocates spectrum worldwide and defines the three ITU regions." + }, + { + "code": "IARU", + "category": "Organisation & Vorschrift", + "meaning": "Internationale Amateur-Radio-Union — International Amateur Radio Union.", + "exam": true, + "explanation": "Worldwide federation of national amateur-radio societies; coordinates band plans and represents amateurs at the ITU." + }, + { + "code": "CEPT", + "category": "Organisation & Vorschrift", + "meaning": "Europäische Konferenz der Verwaltungen für Post und Telekommunikation.", + "exam": true, + "explanation": "Its recommendations let an amateur licensee operate temporarily in other member countries without applying for a separate licence." + }, + { + "code": "BNetzA", + "category": "Organisation & Vorschrift", + "meaning": "Bundesnetzagentur — the German telecommunications regulator.", + "exam": true, + "explanation": "Issues amateur licences and call signs in Germany and supervises the service." + }, + { + "code": "EMV", + "category": "Organisation & Vorschrift", + "meaning": "elektromagnetische Verträglichkeit — electromagnetic compatibility (EMC).", + "exam": true, + "explanation": "The ability of equipment to operate without causing or suffering undue interference." + }, + { + "code": "EMVU", + "category": "Organisation & Vorschrift", + "meaning": "elektromagnetische Umweltverträglichkeit — EMF exposure compliance.", + "exam": true, + "explanation": "Assessment and limits for human exposure to the electromagnetic fields a station produces." + }, + { + "code": "AFu", + "category": "Organisation & Vorschrift", + "meaning": "Amateurfunk — amateur radio.", + "exam": false + } + ] +} diff --git a/test_amateurfunk_shorthand.py b/test_amateurfunk_shorthand.py new file mode 100644 index 0000000..799f9e5 --- /dev/null +++ b/test_amateurfunk_shorthand.py @@ -0,0 +1,277 @@ +"""Tests for amateurfunk_shorthand. + +Like the multiple-choice tests, these inspect the generated `.apkg` +directly (ZIP entries plus the SQLite collection) and never require +Anki itself. They also validate the repo's real `shorthand.json` +against the schema so editorial mistakes surface in CI. +""" + +import json +import sqlite3 +import tempfile +import unittest +import zipfile +from pathlib import Path + +import amateurfunk_shorthand as sh + + +def make_shorthand(): + return { + "q_codes": [ + { + "code": "QSO", + "question": "Can you communicate directly with ...?", + "statement": "I can communicate directly with ...", + "exam": True, + "explanation": "The radio contact itself.", + "example": "DL1ABC: `Tnx for QSO.`", + }, + { + "code": "QRG", + "question": "Will you tell me my exact frequency?", + "statement": "Your exact frequency is ... kHz.", + }, + ], + "abbreviations": [ + { + "code": "73", + "meaning": "Best regards.", + "exam": True, + }, + { + "code": "SK", + "meaning": "End of contact; also silent key.", + "tags": ["prosign"], + }, + ], + } + + +def write_shorthand(root: Path, data=None): + path = root / "shorthand.json" + path.write_text( + json.dumps(data if data is not None else make_shorthand()), + encoding="utf-8", + ) + return path + + +def extract_collection(apkg_path: Path, tmp: Path): + with zipfile.ZipFile(apkg_path) as zf: + zf.extract("collection.anki2", tmp) + media = json.loads(zf.read("media").decode("utf-8")) + names = set(zf.namelist()) + return tmp / "collection.anki2", media, names + + +class TestShorthandBuild(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.shorthand_path = write_shorthand(self.root) + self.out_dir = self.root / "anki" + + def _build(self, **kwargs): + return sh.build_deck( + self.shorthand_path, + self.out_dir, + self.root / "data", # absent → epoch fallback + **kwargs, + ) + + def _collection(self): + apkg = next(self.out_dir.glob("*.apkg")) + return extract_collection(apkg, self.root) + + def test_qgroup_yields_two_notes_abbrev_yields_one(self): + result = self._build() + # 2 Q-groups → 4 notes, 2 abbreviations → 2 notes. + self.assertEqual(result["notes"], 6) + # Two cards per note (forward + reverse). + self.assertEqual(result["cards"], 12) + self.assertEqual(result["q_codes"], 2) + self.assertEqual(result["abbreviations"], 2) + + def test_qgroup_statement_and_question_become_distinct_notes(self): + self._build() + db_path, _media, _names = self._collection() + conn = sqlite3.connect(db_path) + try: + # sfld has INTEGER affinity, so numeric codes (e.g. 73) come + # back as ints; stringify before comparing. + sflds = sorted(str(r[0]) for r in conn.execute("select sfld from notes")) + finally: + conn.close() + self.assertIn("QSO", sflds) + self.assertIn("QSO?", sflds) + + def test_each_note_has_two_cards_one_per_template(self): + self._build() + db_path, _media, _names = self._collection() + conn = sqlite3.connect(db_path) + try: + rows = conn.execute( + "select sfld, ord from notes join cards on cards.nid = notes.id" + " order by sfld, ord" + ).fetchall() + ords_for_qso = sorted(o for s, o in rows if s == "QSO") + finally: + conn.close() + self.assertEqual(ords_for_qso, [0, 1]) + + def test_model_has_two_templates_and_per_template_requirements(self): + self._build() + db_path, _media, _names = self._collection() + conn = sqlite3.connect(db_path) + try: + models = json.loads( + conn.execute("select models from col").fetchone()[0] + ) + finally: + conn.close() + model = next(iter(models.values())) + self.assertEqual([t["name"] for t in model["tmpls"]], ["Bedeutung", "Kürzel"]) + # Forward template needs Code (field 0); reverse needs Meaning (1). + self.assertEqual(model["req"], [[0, "all", [0]], [1, "all", [1]]]) + + def test_tags_mark_kind_exam_and_extra_editorial_tags(self): + self._build() + db_path, _media, _names = self._collection() + conn = sqlite3.connect(db_path) + try: + tags = dict( + conn.execute("select sfld, tags from notes").fetchall() + ) + finally: + conn.close() + self.assertIn(" q-gruppe ", tags["QSO"]) + self.assertIn(" pruefung ", tags["QSO"]) + self.assertIn(" abkuerzung ", tags["SK"]) + self.assertIn(" prosign ", tags["SK"]) + # QRG is not an exam code → no pruefung tag. + self.assertNotIn("pruefung", tags["QRG"]) + + def test_no_media_entries_in_package(self): + self._build() + _db, media, names = self._collection() + self.assertEqual(media, {}) + self.assertEqual(names, {"collection.anki2", "media"}) + + def test_example_backticks_become_code_and_text_is_escaped(self): + self._build() + db_path, _media, _names = self._collection() + conn = sqlite3.connect(db_path) + try: + flds = next( + r[0] for r in conn.execute("select flds from notes") + if r[0].startswith("QSO" + sh.FIELD_SEP) + ) + finally: + conn.close() + self.assertIn("Tnx for QSO.", flds) + + def test_build_is_byte_deterministic_for_same_input(self): + out_a = self.root / "a" + out_b = self.root / "b" + sh.build_deck(self.shorthand_path, out_a, self.root / "data", override_epoch=42) + sh.build_deck(self.shorthand_path, out_b, self.root / "data", override_epoch=42) + for first in sorted(out_a.glob("*.apkg")): + second = out_b / first.name + self.assertEqual(first.read_bytes(), second.read_bytes(), first.name) + + def test_stable_ids_are_unchanged_across_builds(self): + self._build() + db_path, _m, _n = self._collection() + conn = sqlite3.connect(db_path) + try: + first = sorted(r[0] for r in conn.execute("select id from notes")) + finally: + conn.close() + # Rebuild into a fresh dir; ids derive from the code form only. + out_b = self.root / "again" + sh.build_deck(self.shorthand_path, out_b, self.root / "data") + db_b, _m, _n = extract_collection(next(out_b.glob("*.apkg")), out_b) + conn = sqlite3.connect(db_b) + try: + second = sorted(r[0] for r in conn.execute("select id from notes")) + finally: + conn.close() + self.assertEqual(first, second) + + def test_duplicate_code_form_is_rejected(self): + data = make_shorthand() + data["abbreviations"].append({"code": "73", "meaning": "duplicate"}) + path = write_shorthand(self.root, data) + with self.assertRaises(sh.AnkiBuildError) as ctx: + sh.build_deck(path, self.out_dir, self.root / "data") + self.assertIn("73", str(ctx.exception)) + + def test_qgroup_question_form_collides_with_abbrev_question_mark(self): + # A Q-group `QRX` expands to `QRX?`; an abbreviation literally + # named `QRX?` would collide and must be rejected. + data = make_shorthand() + data["q_codes"].append( + {"code": "QRX", "question": "When?", "statement": "Wait."} + ) + data["abbreviations"].append({"code": "QRX?", "meaning": "clash"}) + path = write_shorthand(self.root, data) + with self.assertRaises(sh.AnkiBuildError): + sh.build_deck(path, self.out_dir, self.root / "data") + + def test_missing_required_field_is_rejected(self): + data = make_shorthand() + del data["q_codes"][0]["statement"] + path = write_shorthand(self.root, data) + with self.assertRaises(sh.AnkiBuildError) as ctx: + sh.build_deck(path, self.out_dir, self.root / "data") + self.assertIn("statement", str(ctx.exception)) + + def test_unknown_field_is_rejected(self): + data = make_shorthand() + data["abbreviations"][0]["author"] = "claude" + path = write_shorthand(self.root, data) + with self.assertRaises(sh.AnkiBuildError) as ctx: + sh.build_deck(path, self.out_dir, self.root / "data") + self.assertIn("author", str(ctx.exception)) + + def test_non_bool_exam_is_rejected(self): + data = make_shorthand() + data["abbreviations"][0]["exam"] = "yes" + path = write_shorthand(self.root, data) + with self.assertRaises(sh.AnkiBuildError): + sh.build_deck(path, self.out_dir, self.root / "data") + + def test_missing_file_is_a_hard_error(self): + with self.assertRaises(sh.AnkiBuildError): + sh.build_deck( + self.root / "nope.json", self.out_dir, self.root / "data" + ) + + def test_main_returns_success(self): + rc = sh.main([ + "--shorthand", str(self.shorthand_path), + "--out", str(self.out_dir), + "--data", str(self.root / "data"), + ]) + self.assertEqual(rc, sh.EXIT_OK) + + +class TestRealShorthandFile(unittest.TestCase): + """Validate the repo's actual shorthand.json against the schema.""" + + def test_repo_shorthand_builds(self): + repo = Path(__file__).resolve().parent + path = repo / "shorthand.json" + if not path.exists(): + self.skipTest("shorthand.json not available") + q_codes, abbreviations = sh.load_shorthand(path) + notes = sh.build_notes(q_codes, abbreviations) + self.assertEqual(len(notes), 2 * len(q_codes) + len(abbreviations)) + # Every note carries exactly two cards. + self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_amateurfunk_technical.py b/test_amateurfunk_technical.py new file mode 100644 index 0000000..ccd63a0 --- /dev/null +++ b/test_amateurfunk_technical.py @@ -0,0 +1,213 @@ +"""Tests for amateurfunk_technical. + +Like the other deck tests, these inspect the generated `.apkg` directly +(ZIP entries plus the SQLite collection) and never require Anki. They +also validate the repo's real `technical.json` against the schema. +""" + +import json +import sqlite3 +import tempfile +import unittest +import zipfile +from pathlib import Path + +import amateurfunk_technical as tech +from amateurfunk_anki import FIELD_SEP + + +def make_technical(): + return { + "terms": [ + { + "code": "SSB", + "category": "Betriebsart", + "meaning": "Single sideband.", + "exam": True, + "explanation": "One sideband, carrier suppressed.", + }, + { + "code": "NF", + "category": "Signal & Frequenz", + "meaning": "Niederfrequenz — audio frequency.", + "exam": True, + }, + { + "code": "SDR", + "category": "Digital & Daten", + "meaning": "Software defined radio.", + "tags": ["modern"], + }, + ], + } + + +def write_technical(root: Path, data=None): + path = root / "technical.json" + path.write_text( + json.dumps(data if data is not None else make_technical()), + encoding="utf-8", + ) + return path + + +def extract_collection(apkg_path: Path, tmp: Path): + with zipfile.ZipFile(apkg_path) as zf: + zf.extract("collection.anki2", tmp) + media = json.loads(zf.read("media").decode("utf-8")) + names = set(zf.namelist()) + return tmp / "collection.anki2", media, names + + +class TestTechnicalBuild(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.path = write_technical(self.root) + self.out_dir = self.root / "anki" + + def _build(self, **kwargs): + return tech.build_deck(self.path, self.out_dir, self.root / "data", **kwargs) + + def _collection(self): + apkg = next(self.out_dir.glob("*.apkg")) + return extract_collection(apkg, self.root) + + def test_one_note_two_cards_per_term(self): + result = self._build() + self.assertEqual(result["terms"], 3) + self.assertEqual(result["notes"], 3) + self.assertEqual(result["cards"], 6) + + def test_deck_and_model_are_distinct_from_operating_deck(self): + self._build() + db_path, _media, _names = self._collection() + conn = sqlite3.connect(db_path) + try: + decks = json.loads(conn.execute("select decks from col").fetchone()[0]) + models = json.loads(conn.execute("select models from col").fetchone()[0]) + finally: + conn.close() + self.assertIn(tech.DECK_NAME, [d["name"] for d in decks.values()]) + self.assertEqual( + next(iter(models.values()))["name"], tech.MODEL_NAME + ) + + def test_category_becomes_kind_field_and_tag(self): + self._build() + db_path, _media, _names = self._collection() + conn = sqlite3.connect(db_path) + try: + rows = dict( + (r[0], (r[1], r[2])) + for r in conn.execute("select sfld, flds, tags from notes") + ) + finally: + conn.close() + flds, tags = rows["SSB"] + # Kind field (index 2) carries the German category. + self.assertEqual(flds.split(FIELD_SEP)[2], "Betriebsart") + self.assertIn(" technik ", tags) + self.assertIn(" kategorie-betriebsart ", tags) + self.assertIn(" pruefung ", tags) + # A non-exam term carries no pruefung tag but keeps extra tags. + _flds, sdr_tags = rows["SDR"] + self.assertNotIn("pruefung", sdr_tags) + self.assertIn(" modern ", sdr_tags) + + def test_each_note_has_forward_and_reverse_card(self): + self._build() + db_path, _media, _names = self._collection() + conn = sqlite3.connect(db_path) + try: + rows = conn.execute( + "select sfld, ord from notes join cards on cards.nid = notes.id" + ).fetchall() + finally: + conn.close() + ords_for_ssb = sorted(o for s, o in rows if s == "SSB") + self.assertEqual(ords_for_ssb, [0, 1]) + + def test_no_media_in_package(self): + self._build() + _db, media, names = self._collection() + self.assertEqual(media, {}) + self.assertEqual(names, {"collection.anki2", "media"}) + + def test_build_is_byte_deterministic_for_same_input(self): + out_a = self.root / "a" + out_b = self.root / "b" + tech.build_deck(self.path, out_a, self.root / "data", override_epoch=99) + tech.build_deck(self.path, out_b, self.root / "data", override_epoch=99) + for first in sorted(out_a.glob("*.apkg")): + self.assertEqual( + first.read_bytes(), (out_b / first.name).read_bytes(), first.name + ) + + def test_ids_are_namespaced_apart_from_operating_deck(self): + # A code that exists in both decks (e.g. a hypothetical clash) + # must not share a GUID across the two namespaces. + from amateurfunk_shorthand import make_note as sh_make_note + op = sh_make_note("SSB", "x", "k", "", None, None, " t ", "shorthand") + te = tech.build_notes( + [{"code": "SSB", "category": "Betriebsart", "meaning": "x"}] + )[0] + self.assertNotEqual(op["guid"], te["guid"]) + self.assertNotEqual(op["note_id"], te["note_id"]) + + def test_duplicate_code_is_rejected(self): + data = make_technical() + data["terms"].append( + {"code": "SSB", "category": "Betriebsart", "meaning": "dup"} + ) + path = write_technical(self.root, data) + with self.assertRaises(tech.AnkiBuildError) as ctx: + tech.build_deck(path, self.out_dir, self.root / "data") + self.assertIn("SSB", str(ctx.exception)) + + def test_missing_category_is_rejected(self): + data = make_technical() + del data["terms"][0]["category"] + path = write_technical(self.root, data) + with self.assertRaises(tech.AnkiBuildError) as ctx: + tech.build_deck(path, self.out_dir, self.root / "data") + self.assertIn("category", str(ctx.exception)) + + def test_unknown_field_is_rejected(self): + data = make_technical() + data["terms"][0]["author"] = "claude" + path = write_technical(self.root, data) + with self.assertRaises(tech.AnkiBuildError) as ctx: + tech.build_deck(path, self.out_dir, self.root / "data") + self.assertIn("author", str(ctx.exception)) + + def test_missing_file_is_a_hard_error(self): + with self.assertRaises(tech.AnkiBuildError): + tech.build_deck(self.root / "nope.json", self.out_dir, self.root / "data") + + def test_main_returns_success(self): + rc = tech.main([ + "--technical", str(self.path), + "--out", str(self.out_dir), + "--data", str(self.root / "data"), + ]) + self.assertEqual(rc, tech.EXIT_OK) + + +class TestRealTechnicalFile(unittest.TestCase): + """Validate the repo's actual technical.json against the schema.""" + + def test_repo_technical_builds(self): + repo = Path(__file__).resolve().parent + path = repo / "technical.json" + if not path.exists(): + self.skipTest("technical.json not available") + terms = tech.load_technical(path) + notes = tech.build_notes(terms) + self.assertEqual(len(notes), len(terms)) + self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes)) + + +if __name__ == "__main__": + unittest.main()