748 lines
25 KiB
Python
748 lines
25 KiB
Python
#!/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,
|
|
.card.nightMode .af-sh-example {
|
|
color: #bbb;
|
|
}
|
|
.nightMode .af-sh-example code,
|
|
.nightMode .af-sh-meaning code,
|
|
.card.nightMode .af-sh-example code,
|
|
.card.nightMode .af-sh-meaning code {
|
|
background: #333;
|
|
color: #eee;
|
|
}
|
|
"""
|
|
|
|
|
|
# ============================================================================
|
|
# Build orchestration
|
|
# ============================================================================
|
|
|
|
|
|
def resolve_build_epoch(data_dir, override_epoch):
|
|
"""Pick the package timestamp epoch.
|
|
|
|
Prefers `--epoch`, then the catalog manifest's `fetched_at` (so a
|
|
`make all` build stamps the shorthand deck consistently with the
|
|
catalog decks), and finally falls back to `DEFAULT_BUILD_EPOCH`.
|
|
The deck content is catalog-independent, so an absent data dir just
|
|
means "no manifest epoch available" rather than an error.
|
|
"""
|
|
if override_epoch is not None:
|
|
return int(override_epoch)
|
|
try:
|
|
_edition_dir, manifest, _catalog = load_latest_catalog(data_dir)
|
|
except AnkiBuildError:
|
|
return DEFAULT_BUILD_EPOCH
|
|
return build_epoch_from_manifest(manifest)
|
|
|
|
|
|
def write_glossary_deck(notes, deck_name, model_name, out_dir, build_epoch):
|
|
"""Write `notes` as one two-template `.apkg` and return its path.
|
|
|
|
Shared by both glossary decks (operating shorthand here, technical
|
|
abbreviations in `amateurfunk_technical.py`). The deck carries no
|
|
media — these cards are pure text. The output filename is derived
|
|
from the leaf of `deck_name` (`Amateurfunk::Foo` → `amateurfunk-foo`).
|
|
"""
|
|
slug = slugify(deck_name.split("::")[-1])
|
|
out_path = out_dir / f"amateurfunk-{slug}.apkg"
|
|
deck_id = stable_id("deck", deck_name)
|
|
model_id = stable_id("model", model_name)
|
|
|
|
tmp_dir = out_path.parent / f".{out_path.name}.tmp"
|
|
db_path = tmp_dir / "collection.anki2"
|
|
if tmp_dir.exists():
|
|
shutil.rmtree(tmp_dir)
|
|
tmp_dir.mkdir(parents=True)
|
|
try:
|
|
create_collection_db(
|
|
db_path, deck_id, deck_name, model_id, model_name, notes, build_epoch,
|
|
)
|
|
write_apkg(out_path, db_path, {}, build_epoch=build_epoch)
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
return out_path
|
|
|
|
|
|
def build_deck(shorthand_path, out_dir, data_dir, override_epoch=None):
|
|
"""Build the shorthand `.apkg` and return a small result dict."""
|
|
q_codes, abbreviations = load_shorthand(shorthand_path)
|
|
notes = build_notes(q_codes, abbreviations)
|
|
if not notes:
|
|
raise AnkiBuildError("shorthand database produced no notes")
|
|
build_epoch = resolve_build_epoch(data_dir, override_epoch)
|
|
out_path = write_glossary_deck(
|
|
notes, DECK_NAME, MODEL_NAME, out_dir, build_epoch,
|
|
)
|
|
return {
|
|
"path": out_path,
|
|
"deck": DECK_NAME,
|
|
"notes": len(notes),
|
|
"cards": sum(len(note["card_ids"]) for note in notes),
|
|
"q_codes": len(q_codes),
|
|
"abbreviations": len(abbreviations),
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Main entry point
|
|
# ============================================================================
|
|
|
|
|
|
def _parse_args(argv):
|
|
"""Build the argparse object and parse `argv`."""
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Build an Anki deck of amateur-radio Q-groups and operating "
|
|
"abbreviations from shorthand.json."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--shorthand",
|
|
type=Path,
|
|
default=DEFAULT_SHORTHAND_PATH,
|
|
help="curated source database (default: ./shorthand.json)",
|
|
)
|
|
parser.add_argument(
|
|
"--out",
|
|
type=Path,
|
|
default=DEFAULT_OUT_DIR,
|
|
help="output directory for the .apkg file (default: ./anki)",
|
|
)
|
|
parser.add_argument(
|
|
"--data",
|
|
type=Path,
|
|
default=DEFAULT_DATA_DIR,
|
|
help=(
|
|
"fetch output dir; only used to borrow a deterministic build "
|
|
"epoch from the catalog manifest (default: ./data)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--epoch",
|
|
type=int,
|
|
default=None,
|
|
help="override the package timestamp epoch (default: from manifest)",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv=None):
|
|
"""Top-level entry point. Returns an exit code; never raises."""
|
|
args = _parse_args(argv)
|
|
try:
|
|
result = build_deck(
|
|
args.shorthand,
|
|
args.out,
|
|
args.data,
|
|
override_epoch=args.epoch,
|
|
)
|
|
except AnkiBuildError as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
return EXIT_ERROR
|
|
except Exception as e: # noqa: BLE001 (main() promises not to raise)
|
|
print(f"error: failed to build shorthand deck: {e}", file=sys.stderr)
|
|
return EXIT_ERROR
|
|
|
|
print(
|
|
f"wrote {result['path']} "
|
|
f"({result['notes']} notes, {result['cards']} cards: "
|
|
f"{result['q_codes']} Q-groups, {result['abbreviations']} abbreviations)"
|
|
)
|
|
return EXIT_OK
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|