Add glossary decks

This commit is contained in:
2026-06-17 11:48:27 +02:00
parent fb81e9c1aa
commit 2b73096d3c
9 changed files with 2748 additions and 11 deletions
+45
View File
@@ -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
+9 -3
View File
@@ -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__
+62 -8
View File
@@ -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
+742
View File
@@ -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())
+255
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
]
}
+277
View File
@@ -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()
+213
View File
@@ -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()