Add glossary decks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <code> 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", "<no 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` → <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 <code>.
|
||||
"""
|
||||
return BACKTICK_SPAN_RE.sub(
|
||||
lambda m: f"<code>{m.group(1)}</code>", 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}}<div class=\"af-sh-explanation\">"
|
||||
"{{Explanation}}</div>{{/Explanation}}\n"
|
||||
"{{#Example}}<div class=\"af-sh-example\">{{Example}}</div>{{/Example}}"
|
||||
)
|
||||
front_meta = (
|
||||
'<div class="af-sh-kind">{{Kind}}{{#Form}} · {{Form}}{{/Form}}</div>'
|
||||
)
|
||||
return {
|
||||
"id": model_id,
|
||||
"name": model_name,
|
||||
"type": 0,
|
||||
"mod": now,
|
||||
"usn": -1,
|
||||
"sortf": 0,
|
||||
"did": None,
|
||||
"tmpls": [
|
||||
{
|
||||
"name": "Bedeutung",
|
||||
"ord": 0,
|
||||
"qfmt": (
|
||||
'<div class="af-sh">' + front_meta
|
||||
+ '<div class="af-sh-code">{{Code}}</div>'
|
||||
+ '<div class="af-sh-prompt">Bedeutung?</div></div>'
|
||||
),
|
||||
"afmt": (
|
||||
"{{FrontSide}}\n<hr id=answer>\n"
|
||||
'<div class="af-sh-meaning">{{Meaning}}</div>\n' + extras
|
||||
),
|
||||
"did": None,
|
||||
"bqfmt": "",
|
||||
"bafmt": "",
|
||||
},
|
||||
{
|
||||
"name": "Kürzel",
|
||||
"ord": 1,
|
||||
"qfmt": (
|
||||
'<div class="af-sh">' + front_meta
|
||||
+ '<div class="af-sh-meaning">{{Meaning}}</div>'
|
||||
+ '<div class="af-sh-prompt">Kürzel?</div></div>'
|
||||
),
|
||||
"afmt": (
|
||||
"{{FrontSide}}\n<hr id=answer>\n"
|
||||
'<div class="af-sh-code">{{Code}}</div>\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())
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build an Anki deck of technical and HAM abbreviations.
|
||||
|
||||
A third glossary deck, sibling to `amateurfunk_shorthand.py`. Where that
|
||||
script covers *operating* shorthand (Q-groups, CQ, 73), this one covers
|
||||
the *technical* vocabulary: modulation and modes (SSB, FM, CW), signal
|
||||
domains (NF, HF, ZF), building blocks (VFO, PLL, AGC), components,
|
||||
measurements (dB, SWR, PEP), propagation, digital modes, and the
|
||||
organisations/regulations a candidate meets — the exam terms plus the
|
||||
common HAM abbreviations beyond it (real knowledge, not just the test).
|
||||
|
||||
The card mechanics are identical to the operating deck — one note per
|
||||
abbreviation with two templates (code → meaning and the reverse) — so
|
||||
this script is deliberately thin: it loads and validates
|
||||
`technical.json`, turns each entry into a note, and hands the rest to
|
||||
the shared glossary machinery in `amateurfunk_shorthand`. Each entry
|
||||
also carries a German `category` (Betriebsart, Bauteil, …) shown on the
|
||||
card and used as a filter tag.
|
||||
|
||||
Stdlib only, like its siblings.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from amateurfunk_anki import AnkiBuildError, slugify
|
||||
|
||||
# The glossary deck machinery is shared with the operating-shorthand
|
||||
# script: the two-template note type, the two-cards-per-note collection
|
||||
# writer, the deck packager, the build-epoch resolver, and the per-entry
|
||||
# schema validator. Only the data shape, the deck/model names, and the
|
||||
# tag scheme are specific to the technical deck.
|
||||
from amateurfunk_shorthand import (
|
||||
make_note,
|
||||
resolve_build_epoch,
|
||||
validate_entry,
|
||||
write_glossary_deck,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
# Curated source database (editorial content tracked in git).
|
||||
DEFAULT_TECHNICAL_PATH = Path("technical.json")
|
||||
|
||||
# Catalog output dir, consulted only to borrow a deterministic build
|
||||
# epoch from the manifest (the deck content is catalog-independent).
|
||||
DEFAULT_DATA_DIR = Path("data")
|
||||
|
||||
# Shared output dir for every .apkg.
|
||||
DEFAULT_OUT_DIR = Path("anki")
|
||||
|
||||
# Deck and note-type names. Both distinct from the operating deck so the
|
||||
# two never merge on import, and the IDs live in their own namespace.
|
||||
DECK_NAME = "Amateurfunk::Technische Abkürzungen"
|
||||
MODEL_NAME = "Amateurfunk Technische Abkürzung"
|
||||
|
||||
# ID/GUID namespace for this deck (kept separate from the operating
|
||||
# deck's "shorthand" namespace).
|
||||
ID_NAMESPACE = "technical"
|
||||
|
||||
# Schema for one entry in `technical.json`. `category` is the German
|
||||
# kind label shown on the card; it is required so nothing ships
|
||||
# uncategorised.
|
||||
TERM_REQUIRED = ("code", "category", "meaning")
|
||||
TERM_OPTIONAL = ("exam", "explanation", "example", "tags")
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_ERROR = 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Loading and validating the curated database
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def load_technical(path):
|
||||
"""Return the validated list of term entries from a JSON file.
|
||||
|
||||
Mandatory file with a strict shape (same contract as the operating
|
||||
deck): a missing or malformed file is a hard error, and unknown
|
||||
fields are rejected so a typo can't silently drop content.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(path.read_text("utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
raise AnkiBuildError(f"could not read technical file {path}: {e}") from e
|
||||
if not isinstance(data, dict):
|
||||
raise AnkiBuildError(
|
||||
f"technical file {path} must contain a JSON object at the top level"
|
||||
)
|
||||
terms = data.get("terms", [])
|
||||
if not isinstance(terms, list):
|
||||
raise AnkiBuildError(f"technical file {path}: 'terms' must be a list")
|
||||
for entry in terms:
|
||||
validate_entry(entry, TERM_REQUIRED, TERM_OPTIONAL, "terms")
|
||||
_check_no_duplicate_codes(terms)
|
||||
return terms
|
||||
|
||||
|
||||
def _check_no_duplicate_codes(terms):
|
||||
"""Raise if two terms share a code — that would overwrite a note.
|
||||
|
||||
Every stable ID and GUID is hashed from the code, so a duplicate
|
||||
would silently clobber the first note on import.
|
||||
"""
|
||||
codes = [entry["code"] for entry in terms]
|
||||
duplicates = sorted({code for code in codes if codes.count(code) > 1})
|
||||
if duplicates:
|
||||
raise AnkiBuildError(
|
||||
"duplicate code(s) in technical database: " + ", ".join(duplicates)
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Note expansion
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def build_notes(terms):
|
||||
"""Turn each term into one two-card note (forward + reverse).
|
||||
|
||||
The German `category` becomes the note's `Kind` (shown on the card);
|
||||
there is no usage form, so `Form` is empty. Identity is hashed from
|
||||
the code under this deck's own ID namespace.
|
||||
"""
|
||||
notes = []
|
||||
for entry in terms:
|
||||
notes.append(
|
||||
make_note(
|
||||
code_display=entry["code"],
|
||||
meaning=entry["meaning"],
|
||||
kind=entry["category"],
|
||||
form="",
|
||||
explanation=entry.get("explanation"),
|
||||
example=entry.get("example"),
|
||||
tags=tags_for_term(entry),
|
||||
namespace=ID_NAMESPACE,
|
||||
)
|
||||
)
|
||||
return notes
|
||||
|
||||
|
||||
def tags_for_term(entry):
|
||||
"""Return the Anki tags field (space-padded) for one term.
|
||||
|
||||
A base `technik` tag, a `kategorie-<slug>` tag from the German
|
||||
category, a `pruefung` tag when the term appears in the exam
|
||||
catalog, and any extra editorial tags carried on the entry.
|
||||
"""
|
||||
tags = ["technik", f"kategorie-{slugify(entry['category'])}"]
|
||||
if entry.get("exam"):
|
||||
tags.append("pruefung")
|
||||
tags.extend(slugify(tag) for tag in entry.get("tags", []))
|
||||
return " " + " ".join(tags) + " "
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Build orchestration
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def build_deck(technical_path, out_dir, data_dir, override_epoch=None):
|
||||
"""Build the technical `.apkg` and return a small result dict."""
|
||||
terms = load_technical(technical_path)
|
||||
notes = build_notes(terms)
|
||||
if not notes:
|
||||
raise AnkiBuildError("technical database produced no notes")
|
||||
build_epoch = resolve_build_epoch(data_dir, override_epoch)
|
||||
out_path = write_glossary_deck(
|
||||
notes, DECK_NAME, MODEL_NAME, out_dir, build_epoch,
|
||||
)
|
||||
return {
|
||||
"path": out_path,
|
||||
"deck": DECK_NAME,
|
||||
"notes": len(notes),
|
||||
"cards": sum(len(note["card_ids"]) for note in notes),
|
||||
"terms": len(terms),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main entry point
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _parse_args(argv):
|
||||
"""Build the argparse object and parse `argv`."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Build an Anki deck of amateur-radio technical and HAM "
|
||||
"abbreviations from technical.json."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--technical",
|
||||
type=Path,
|
||||
default=DEFAULT_TECHNICAL_PATH,
|
||||
help="curated source database (default: ./technical.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=DEFAULT_OUT_DIR,
|
||||
help="output directory for the .apkg file (default: ./anki)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data",
|
||||
type=Path,
|
||||
default=DEFAULT_DATA_DIR,
|
||||
help=(
|
||||
"fetch output dir; only used to borrow a deterministic build "
|
||||
"epoch from the catalog manifest (default: ./data)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epoch",
|
||||
type=int,
|
||||
default=None,
|
||||
help="override the package timestamp epoch (default: from manifest)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""Top-level entry point. Returns an exit code; never raises."""
|
||||
args = _parse_args(argv)
|
||||
try:
|
||||
result = build_deck(
|
||||
args.technical,
|
||||
args.out,
|
||||
args.data,
|
||||
override_epoch=args.epoch,
|
||||
)
|
||||
except AnkiBuildError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return EXIT_ERROR
|
||||
except Exception as e: # noqa: BLE001 (main() promises not to raise)
|
||||
print(f"error: failed to build technical deck: {e}", file=sys.stderr)
|
||||
return EXIT_ERROR
|
||||
|
||||
print(
|
||||
f"wrote {result['path']} "
|
||||
f"({result['notes']} notes, {result['cards']} cards: "
|
||||
f"{result['terms']} technical abbreviations)"
|
||||
)
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+529
@@ -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.`"
|
||||
}
|
||||
]
|
||||
}
|
||||
+616
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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("<code>Tnx for QSO.</code>", 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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user