diff --git a/README.md b/README.md
index 12abe8f..e316f09 100644
--- a/README.md
+++ b/README.md
@@ -186,7 +186,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
|-------|---------|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
-| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `anki-progress` (read-only family study-progress dashboard for an `anki-sync-server` instance — reviews/accuracy/streak per account, plus an ntfy notification once a study session has been going for a configurable number of minutes; reads collection files with SQLite's read-only mode so it can't interfere with the live sync server), `anki-sync-server` (self-hosted sync backend for the Anki flashcard app — spaced-repetition scheduling stays in the Anki client, this just syncs collections across devices without AnkiWeb; supports multiple independent accounts per instance), `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
+| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `anki-progress` (read-only study-progress dashboard for an `anki-sync-server` instance — reviews/accuracy/streak per account, plus an ntfy notification once a study session has been going for a configurable number of minutes; reads collection files with SQLite's read-only mode so it can't interfere with the live sync server), `anki-sync-server` (self-hosted sync backend for the Anki flashcard app — spaced-repetition scheduling stays in the Anki client, this just syncs collections across devices without AnkiWeb; supports multiple independent accounts per instance), `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
@@ -293,6 +293,28 @@ backup
+## Generating Anki decks (tools/anki-deck-*.py)
+
+`anki-sync-server` gives you a self-hosted sync backend, but a fresh
+account has no content — `tools/anki-deck-math.py`,
+`tools/anki-deck-periodic.py`, and `tools/anki-deck-visual.py` generate
+ready-to-import `.apkg` decks (multiplication/division/addition/
+subtraction/fractions/decimals, the periodic table, and shapes/clocks/
+coin-counting) with Anki's built-in type-the-answer input and offline
+neural TTS audio (Piper) on every card. All three are standalone Python
+scripts, unrelated to the `services/*.sh` installer framework — run them
+on any machine with Python, not necessarily the server itself. Full setup
+(a venv, `genanki` + `piper-tts`, downloading a voice) and every deck's
+exact usage is documented in `tools/anki-deck-math.py`'s own header
+docstring; the other two scripts point back to it rather than repeating
+the same instructions three times.
+
+Shapes, clocks, and coin images are drawn programmatically (SVG) rather
+than AI-generated — image generation is a poor fit for content that has
+to be exactly correct (an exact clock time, an exact side count), not
+just plausible-looking; see `tools/anki-deck-visual.py`'s own docstring
+for more on that tradeoff.
+
## Layout
```
diff --git a/services/anki-progress.sh b/services/anki-progress.sh
index f34e839..1177947 100644
--- a/services/anki-progress.sh
+++ b/services/anki-progress.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-# services/anki-progress.sh — Family Anki study-progress dashboard + ntfy
+# services/anki-progress.sh — Anki study-progress dashboard + ntfy
# "started studying" notifications. Reads an anki-sync-server instance's
# data directly (read-only) — see services/anki-sync-server.sh, which this
# service requires.
@@ -208,7 +208,7 @@ CBLOCK
fi
# ─────────────────────────────────────────────────────────────────────────────
-register_service anki-progress utilities "Family Anki study-progress dashboard + ntfy 'started studying' notifications (reads an anki-sync-server instance's data read-only)" 8099
+register_service anki-progress utilities "Anki study-progress dashboard + ntfy 'started studying' notifications (reads an anki-sync-server instance's data read-only)" 8099
install_anki-progress() {
require_docker || return 1
@@ -323,7 +323,7 @@ install_anki-progress() {
else
prompt_text " ntfy server URL (e.g. https://ntfy.yourdomain.com, or https://ntfy.sh):" "https://ntfy.sh" NTFY_URL
fi
- prompt_text " ntfy topic to publish 'started studying' notifications to:" "family-anki" NTFY_TOPIC
+ prompt_text " ntfy topic to publish 'started studying' notifications to:" "anki-progress" NTFY_TOPIC
local SESSION_GAP_MINUTES="" NOTIFY_DELAY_MINUTES=""
prompt_text " Minutes of inactivity that counts as a new study session starting:" "30" SESSION_GAP_MINUTES
@@ -356,7 +356,7 @@ networks:
backup_if_exists app.py
cat > app.py << 'PYEOF'
#!/usr/bin/env python3
-"""Family Anki progress dashboard + ntfy "started studying" notifications.
+"""Anki study-progress dashboard + ntfy "started studying" notifications.
Reads every account's collection.anki2 directly (READ-ONLY — never opens for
write, so it can't corrupt live data the sync server or a client is using)
@@ -387,7 +387,7 @@ import requests
from flask import Flask, render_template_string
NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.example.com")
-NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "family-anki")
+NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "anki-progress")
SESSION_GAP_MINUTES = int(os.environ.get("SESSION_GAP_MINUTES", 30))
NOTIFY_DELAY_MINUTES = int(os.environ.get("NOTIFY_DELAY_MINUTES", 10))
@@ -502,7 +502,7 @@ def detect_current_session_start(revlog_rows, now_ms):
DASHBOARD_TEMPLATE = """
-
Family Anki Progress
+Anki Progress
-
Family Anki Progress
+
Anki Progress
{% for user, s in stats.items() %}
@@ -667,8 +667,8 @@ ENVEOF
fi
# ── Caddy + Authelia-aware protection ────────────────────────────────────
- # This dashboard shows every family member's personal study activity —
- # same "sensitive, protect by default" reasoning as
+ # This dashboard shows every account's personal study activity — same
+ # "sensitive, protect by default" reasoning as
# services/security-dashboard.sh: auto-use local Authelia if present, no
# prompt needed; otherwise warn clearly and offer a remote instance,
# since leaving it open is a real privacy tradeoff, not a neutral default.
@@ -677,9 +677,9 @@ ENVEOF
EXTRA_BLOCK=" import authelia"
log_info "Local Authelia detected — protecting with it."
else
- log_warning "No local Authelia found. This dashboard shows every family"
- log_warning "member's personal study activity — recommend protecting it"
- log_warning "before exposing it publicly."
+ log_warning "No local Authelia found. This dashboard shows every account's"
+ log_warning "personal study activity — recommend protecting it before"
+ log_warning "exposing it publicly."
local _use_remote=""
prompt_yn " Protect with a remote Authelia instance (e.g. on a homelab)? (y/n):" "y" _use_remote
if [[ "$_use_remote" =~ ^[Yy]$ ]]; then
diff --git a/tools/anki-deck-math.py b/tools/anki-deck-math.py
new file mode 100644
index 0000000..4413089
--- /dev/null
+++ b/tools/anki-deck-math.py
@@ -0,0 +1,420 @@
+#!/usr/bin/env python3
+"""tools/anki-deck-math.py — Generate math-fact Anki decks (.apkg) with a
+vertical/stacked problem layout, Anki's built-in type-the-answer input, and
+Piper (offline, local neural TTS) audio on both the question and answer
+side of every card.
+
+Standalone content-generation tool, unrelated to this repo's services/*.sh
+installers — run it on any machine with Python (your desktop, laptop, or
+the same box running services/anki-sync-server.sh), then import the
+resulting .apkg into Anki (File -> Import) or push it into a sync-server
+account with AnkiConnect's importPackage action. See services/anki-sync-server.sh
+and services/anki-progress.sh for the actual self-hosted sync backend and
+progress dashboard this content is meant to be studied through.
+
+Decks:
+ multiplication 1-12, all 144 ordered pairs (a x b), shuffled
+ (not sequential — see the note near
+ random.shuffle(pairs) below for why)
+ division inverse of the multiplication deck (144 facts)
+ addsub --lo L --hi H addition + subtraction fact family for [L, H]
+ (subtraction facts derived from the addition
+ facts, e.g. 7+3=10 also gives 10-7=3 and
+ 10-3=7 — never negative results)
+ fractions reducing fractions to lowest terms (denominators 2-12)
+ decimals fraction -> decimal conversion (only denominators
+ whose decimal expansion terminates: 2,4,5,8,10,20,25)
+
+Setup (one time):
+ python3 -m venv ~/anki-deck-venv
+ source ~/anki-deck-venv/bin/activate
+ pip install genanki piper-tts
+
+ # Download at least one voice (one time per voice you want to try —
+ # download_voices saves into the CURRENT directory by default, so cd
+ # somewhere sensible first, e.g. your home directory):
+ python3 -m piper.download_voices en_US-lessac-medium
+ # Other options: en_US-amy-medium (warm/friendly), en_US-ryan-high
+ # (best-quality US male), en_US-libritts_r-medium (multi-speaker),
+ # en_GB-alba-medium / en_GB-cori-high (British accent). Tiers are
+ # low < medium < high — higher sounds more natural but is bigger/slower.
+
+ # Sanity-check the voice before generating a full deck's worth of clips:
+ echo "three times seven" | python3 -m piper -m en_US-lessac-medium.onnx -f /tmp/test.wav
+ # play /tmp/test.wav and confirm it sounds right first.
+
+Usage (run with the venv activated):
+ python3 anki-deck-math.py --deck multiplication
+ python3 anki-deck-math.py --deck division
+ python3 anki-deck-math.py --deck addsub --lo 3 --hi 7
+ python3 anki-deck-math.py --deck addsub --lo 3 --hi 13
+ python3 anki-deck-math.py --deck addsub --lo 2 --hi 21
+ python3 anki-deck-math.py --deck fractions
+ python3 anki-deck-math.py --deck decimals
+(add --voice en_US-amy-medium etc. to any of the above to use a voice other
+than the default en_US-lessac-medium; --model-path to point at a voice
+file directly if it's not found in any of the usual places checked
+automatically; --dry-run-tts to test the deck-building logic itself
+without Piper or any voice model at all, using silent placeholder audio)
+
+The addsub --hi 21 deck generates ~1600 audio clips and will take noticeably
+longer than the others — consider `nohup python3 anki-deck-math.py --deck
+addsub --lo 2 --hi 21 > addsub.log 2>&1 &` if you don't want to wait on it.
+"""
+import argparse
+import hashlib
+import genanki
+import math
+import os
+import random
+import subprocess
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--deck", required=True,
+ choices=["multiplication", "division", "addsub", "fractions", "decimals"])
+parser.add_argument("--lo", type=int, default=None, help="addsub only: low end of range")
+parser.add_argument("--hi", type=int, default=None, help="addsub only: high end of range")
+parser.add_argument("--voice", default="en_US-lessac-medium")
+parser.add_argument("--model-path", default=None)
+parser.add_argument("--dry-run-tts", action="store_true",
+ help="Skip Piper entirely and write silent placeholder audio instead"
+ " (for testing the deck-building logic without a voice model).")
+args = parser.parse_args()
+
+if args.deck == "addsub":
+ if args.lo is None or args.hi is None:
+ raise SystemExit("--deck addsub requires --lo and --hi, e.g. --lo 3 --hi 7")
+ if args.lo >= args.hi:
+ raise SystemExit("--lo must be less than --hi")
+
+SCRATCH = os.path.dirname(os.path.abspath(__file__))
+
+# ─── Voice resolution (same search order as the multiplication script) ──────
+_CANDIDATES = [
+ args.model_path,
+ f"{args.voice}.onnx",
+ os.path.join(SCRATCH, f"{args.voice}.onnx"),
+ os.path.expanduser(f"~/{args.voice}.onnx"),
+ os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
+]
+VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
+
+if VOICE_MODEL is None and not args.dry_run_tts:
+ raise SystemExit(
+ f"Voice model for '{args.voice}' not found. Checked:\n"
+ + "\n".join(f" {p}" for p in _CANDIDATES if p)
+ + f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
+ + "\nThen pass its exact path with --model-path /the/real/path.onnx"
+ + "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
+ )
+
+
+def piper_tts(text: str, out_path: str) -> None:
+ if args.dry_run_tts:
+ # 44-byte minimal valid WAV header, zero samples — enough for genanki
+ # to accept it as a real media file without needing Piper installed.
+ with open(out_path, "wb") as f:
+ f.write(
+ b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
+ b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
+ )
+ return
+ subprocess.run(
+ ["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
+ input=text.encode("utf-8"),
+ check=True,
+ capture_output=True,
+ )
+
+
+# ─── Number -> words ─────────────────────────────────────────────────────────
+ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
+ "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
+ "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
+TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
+ "eighty", "ninety"]
+
+
+def num2words(n):
+ if n < 0:
+ return "negative " + num2words(-n)
+ if n < 20:
+ return ONES[n]
+ if n < 100:
+ t, o = divmod(n, 10)
+ return TENS[t] + ("-" + ONES[o] if o else "")
+ h, rem = divmod(n, 100)
+ return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
+
+
+_NUM_CHECKS = {0: "zero", 9: "nine", 10: "ten", 13: "thirteen", 20: "twenty",
+ 21: "twenty-one", 45: "forty-five", 99: "ninety-nine",
+ 100: "one hundred", 110: "one hundred ten",
+ 121: "one hundred twenty-one", 144: "one hundred forty-four",
+ 441: "four hundred forty-one"}
+for _n, _w in _NUM_CHECKS.items():
+ assert num2words(_n) == _w, f"num2words({_n}) = {num2words(_n)!r}, expected {_w!r}"
+
+# Ordinal words, singular form, denominators 2-21 (covers every deck below).
+# Irregular forms (half, third, fifth, eighth, ninth, twelfth) are real
+# English irregularities, not a suffix rule, so this is a lookup table, not
+# a formula — a formula would get exactly these wrong.
+ORDINAL_SINGULAR = {
+ 2: "half", 3: "third", 4: "fourth", 5: "fifth", 6: "sixth",
+ 7: "seventh", 8: "eighth", 9: "ninth", 10: "tenth", 11: "eleventh",
+ 12: "twelfth", 13: "thirteenth", 14: "fourteenth", 15: "fifteenth",
+ 16: "sixteenth", 17: "seventeenth", 18: "eighteenth", 19: "nineteenth",
+ 20: "twentieth", 21: "twenty-first", 25: "twenty-fifth", 50: "fiftieth",
+ 100: "hundredth",
+}
+
+
+def ordinal_plural(n):
+ s = ORDINAL_SINGULAR[n]
+ return "halves" if s == "half" else s + "s"
+
+
+def fraction_words(num, den):
+ """'three fourths', 'one half', 'seven tenths'."""
+ ord_word = ORDINAL_SINGULAR[den] if num == 1 else ordinal_plural(den)
+ return f"{num2words(num)} {ord_word}"
+
+
+_FRAC_CHECKS = {
+ (1, 2): "one half", (3, 4): "three fourths", (1, 4): "one fourth",
+ (7, 10): "seven tenths", (1, 3): "one third", (2, 3): "two thirds",
+ (5, 8): "five eighths", (1, 8): "one eighth",
+}
+for (_n, _d), _w in _FRAC_CHECKS.items():
+ assert fraction_words(_n, _d) == _w, f"fraction_words({_n},{_d}) = {fraction_words(_n, _d)!r}, expected {_w!r}"
+
+
+def decimal_words(decimal_str):
+ """'0.25' -> 'zero point two five' (each digit spoken individually,
+ avoids any ambiguity between e.g. 'point two five' vs 'twenty-five
+ hundredths')."""
+ whole, frac = decimal_str.split(".")
+ digit_words = " ".join(ONES[int(d)] for d in frac)
+ return f"{num2words(int(whole))} point {digit_words}"
+
+
+assert decimal_words("0.25") == "zero point two five"
+assert decimal_words("0.5") == "zero point five"
+assert decimal_words("0.375") == "zero point three seven five"
+
+
+# ─── Shared genanki model builder ────────────────────────────────────────────
+# Every deck here renders as two stacked lines with a line under them (same
+# visual language as the original multiplication deck): TOP over BOTTOM,
+# with an optional prefix (operator) on the bottom line. Fractions/decimals
+# reuse the exact same layout as numerator-over-denominator.
+def build_model(deck_key):
+ voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
+ model_id = 1_600_000_000 + (voice_hash % 90_000_000)
+ return model_id, genanki.Model(
+ model_id,
+ f"Math Fact ({deck_key}, {args.voice})",
+ fields=[{"name": "Top"}, {"name": "Bottom"}, {"name": "Answer"},
+ {"name": "QSound"}, {"name": "ASound"}],
+ templates=[{
+ "name": "Card",
+ "qfmt": """
+