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-technische-kenntnisse-a.apkg
|
||||||
├── amateurfunk-betriebliche-kenntnisse.apkg
|
├── amateurfunk-betriebliche-kenntnisse.apkg
|
||||||
└── amateurfunk-kenntnisse-von-vorschriften.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`
|
### 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,
|
The Anki design decisions (shuffle seeding, deterministic build epoch,
|
||||||
SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
|
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
|
## Repo conventions
|
||||||
|
|
||||||
- Python 3.11+, standard library only. No third-party dependencies
|
- 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:
|
fetch:
|
||||||
python3 amateurfunk_fetch.py
|
python3 amateurfunk_fetch.py
|
||||||
@@ -8,8 +8,14 @@ fetch:
|
|||||||
anki:
|
anki:
|
||||||
python3 amateurfunk_anki.py
|
python3 amateurfunk_anki.py
|
||||||
|
|
||||||
|
shorthand:
|
||||||
|
python3 amateurfunk_shorthand.py
|
||||||
|
|
||||||
|
technical:
|
||||||
|
python3 amateurfunk_technical.py
|
||||||
|
|
||||||
test:
|
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:
|
clean:
|
||||||
rm -rf data anki __pycache__
|
rm -rf data anki __pycache__
|
||||||
|
|||||||
@@ -6,19 +6,28 @@ Bundesnetzagentur and turn it into Anki decks.
|
|||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make # fetch + build (default)
|
make # fetch + build everything (default)
|
||||||
make fetch # download + extract the catalog only → data/
|
make fetch # download + extract the catalog only → data/
|
||||||
make anki # rebuild .apkg files from data/ → anki/
|
make anki # rebuild the five exam decks from data/ → anki/
|
||||||
make test # run both test suites
|
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/
|
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);
|
Vorschriften get one deck each (shared across all license classes);
|
||||||
Technische is split per class into three decks (N / E / A) following
|
Technische is split per class into three decks (N / E / A) following
|
||||||
the catalog's `class` field. A class-A candidate who wants every
|
the catalog's `class` field. A class-A candidate who wants every
|
||||||
Technische question imports all three Technische decks. Re-importing
|
Technische question imports all three Technische decks.
|
||||||
a newer build preserves your review history.
|
|
||||||
|
**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
|
## 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
|
Anki by deck, by the `klasse-N|E|A` tag, or by the `Number` field
|
||||||
prefix.
|
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
|
## Exam question source
|
||||||
|
|
||||||
The catalog is published by the Bundesnetzagentur (BNetzA), the German
|
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
|
- `references/Call-Signs.md` — call-sign patterns, suffixes, and country
|
||||||
prefixes that appear in the catalog.
|
prefixes that appear in the catalog.
|
||||||
- `references/Q-Codes.md` — Q-codes and operating shorthand used in the
|
- `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
|
## 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.md` — source-discovery notes, JSON schema, per-stage
|
||||||
design contracts.
|
design contracts.
|
||||||
- `EXPLANATIONS.md` — schema + workflows for the explanations
|
- `EXPLANATIONS.md` — schema + workflows for the explanations
|
||||||
database.
|
database.
|
||||||
|
- `shorthand.json` / `technical.json` — curated source data for the
|
||||||
|
two [glossary decks](#glossary-decks).
|
||||||
|
|
||||||
## License
|
## 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