Author SHA1 Message Date
Claude 102b0405b4 Drop family framing from anki-progress; add anki-deck-*.py tools
anki-progress.sh and its embedded app.py assumed a family/kids use case
(dashboard title, ntfy topic default, Authelia warning text, comments)
that was never actually stated — nothing in this repo should assume who
the accounts belong to. Retitled to plain "Anki Progress" throughout,
default ntfy topic changed from family-anki to anki-progress, and every
"family member" reference reworded to "account".

Also adds tools/anki-deck-math.py, tools/anki-deck-periodic.py, and
tools/anki-deck-visual.py — the Anki deck-generation scripts developed
earlier in this session, now committed as standalone, self-documented
tools (same tools/*.{sh,py} convention as tools/dedupe-finder.py) rather
than living only in chat. Each script's own header docstring carries the
full one-time setup (venv, genanki + piper-tts, downloading a voice) and
usage — anki-deck-periodic.py and anki-deck-visual.py point back to
anki-deck-math.py's copy rather than repeating it three times. Content is
generic (multiplication/division/addition/subtraction/fractions/decimals,
the periodic table, shapes/clocks/coin-counting) — nothing here assumes
who's using it or why.

Re-verified after the rename: the embedded app.py still passes its full
logic test suite once written out by the installer, and all three
tools/anki-deck-*.py scripts still build correct decks under
--dry-run-tts after their docstrings were rewritten.

Adds a README.md section pointing at the three scripts, and updates the
anki-progress Services table entry to drop "family" from its wording.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 19:34:48 +00:00
Claude 9c7a054d97 Add anki-progress service — family Anki study-progress dashboard
Read-only dashboard + ntfy notifications for an anki-sync-server
instance, following this repo's standard Docker-service template
(multi-instance, port scanning, DRY_RUN, update/fresh/cancel).

Dashboard shows reviews today/this week, accuracy, and current streak
per account. A background loop detects when a study session starts
(first review after a configurable inactivity gap, default 30 min) and
sends one ntfy notification a configurable delay later (default 10 min,
per request) if the session is still going — not on every review, and
not twice for the same session.

Reads every account's collection.anki2 with SQLite's read-only mode
(file:...?mode=ro) — never opens for write, so it can't corrupt or lock
out the live sync server or a syncing client. Verified this concurrently
against a real writer with no lock conflict, plus the streak/session/
notify-state logic against synthetic review timelines covering gapped
streaks, multi-session boundaries, and the no-duplicate-notification
requirement, before ever writing the installer around it.

Requires an anki-sync-server instance (hard dependency, checked at
install time, chains only that one direction per this repo's
"Chaining into another service" convention) and auto-detects a local
ntfy install to reach it directly over caddy_net instead of requiring
a public URL. Follows security-dashboard.sh's Authelia pattern: local
Authelia used automatically, remote Authelia offered otherwise, since
this exposes every family member's personal study activity.

Updates the Services table in README.md per the three-step rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 19:18:48 +00:00
5 changed files with 1977 additions and 1 deletions
+23 -1
View File
@@ -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 | | `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) | | `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-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` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
@@ -293,6 +293,28 @@ backup
</details> </details>
## 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 ## Layout
``` ```
+741
View File
@@ -0,0 +1,741 @@
#!/bin/bash
# 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.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash anki-progress.sh
# (Docker must already be installed, and an anki-sync-server instance must
# already exist on the same box, when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Already installed."
read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r
case "${_r,,}" in
u|update) eval "$_var='update'" ;;
f|fresh) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
fi
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
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
log_info "Installing Anki Progress Dashboard..."
# ── Dependency: needs an anki-sync-server instance already installed ────
# Meaningless on its own — see CLAUDE.md's "Chaining into another
# service" section. Only chains one direction: anki-progress requires
# anki-sync-server, never the reverse.
local _sync_dirs=()
local _d
for _d in "$DOCKER_DIR"/anki-sync-server*; do
[ -d "$_d" ] && _sync_dirs+=("$(basename "$_d")")
done
if [ "${#_sync_dirs[@]}" -eq 0 ]; then
log_error "No anki-sync-server install found — this dashboard reads its data directly."
log_error "Install it first: sudo ./setup.sh anki-sync-server"
return 1
fi
local SYNC_INSTANCE="${_sync_dirs[0]}"
if [ "${#_sync_dirs[@]}" -gt 1 ] && [ "$UNATTENDED" != true ]; then
echo ""
echo " Multiple anki-sync-server instances found:"
local i
for i in "${!_sync_dirs[@]}"; do
echo " $((i + 1))) ${_sync_dirs[$i]}"
done
local _choice=""
prompt_text " Which one should this dashboard monitor? [1]:" "1" _choice
if [[ "$_choice" =~ ^[0-9]+$ ]] && [ "$_choice" -ge 1 ] && [ "$_choice" -le "${#_sync_dirs[@]}" ]; then
SYNC_INSTANCE="${_sync_dirs[$((_choice - 1))]}"
fi
fi
local SYNC_DATA_DIR="$DOCKER_DIR/$SYNC_INSTANCE/data"
# ── Instance selection (of this dashboard itself) ───────────────────────
# A second instance is a real use case (e.g. a second household with its
# own anki-sync-server and its own dashboard) — same multi-instance
# pattern as every other service here (see CLAUDE.md).
local AP_DIR="$DOCKER_DIR/anki-progress"
local INSTANCE_SUFFIX="" CONTAINER="anki-progress"
local WEB_PORT="8099"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would verify an anki-sync-server instance exists ($SYNC_INSTANCE found)"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create $AP_DIR(-<name>) with app.py, Dockerfile, docker-compose.yml"
echo "[DRY-RUN] Would prompt for ntfy URL/topic and notification timing"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
if [ -d "$AP_DIR" ]; then
echo ""
echo " Anki Progress Dashboard is already installed at $AP_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate dashboard instance alongside it"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/anki-progress-$_suffix" ]; then
log_warning "anki-progress-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
AP_DIR="$DOCKER_DIR/anki-progress-$_suffix"
CONTAINER="anki-progress-$_suffix"
log_info "New instance: $AP_DIR"
else
if [[ -f "$AP_DIR/docker-compose.yml" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing app code + rebuilding the image — ntfy config and Caddy setup are left as-is."
( cd "$AP_DIR" && docker compose up -d --build ) \
&& log_success "Anki Progress Dashboard refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $AP_DIR/docker-compose.yml logs"
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) ;;
esac
fi
fi
fi
find_free_port WEB_PORT "$WEB_PORT"
# ── ntfy ──────────────────────────────────────────────────────────────
# If ntfy is installed locally, reach it directly over caddy_net by
# container name — avoids a round trip through the public internet for
# a purely internal notification. Otherwise ask for a full URL (a
# remote/self-hosted instance elsewhere, or public ntfy.sh).
local NTFY_URL="" NTFY_TOPIC=""
if [ -d "$DOCKER_DIR/ntfy" ]; then
log_info "Local ntfy install detected — reaching it directly over caddy_net."
NTFY_URL="http://ntfy:80"
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:" "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
prompt_text " Minutes after a session starts to send the notification:" "10" NOTIFY_DELAY_MINUTES
mkdir -p "$AP_DIR/state"
ensure_docker_dir_ownership "$AP_DIR"
cd "$AP_DIR" || return 1
# Mirrors configure_caddy_for_service's own mode resolution — only
# "local" joins caddy_net.
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
local _CADDY_NET_BLOCK=""
local _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_BLOCK=" networks:
- caddy_net
"
_CADDY_NET_SECTION="
networks:
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
backup_if_exists app.py
cat > app.py << 'PYEOF'
#!/usr/bin/env python3
"""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)
from the anki-sync-server's data directory, and:
1. Serves a small web dashboard (reviews today/week, accuracy, streak,
last active) per account.
2. Runs a background loop that detects when a new study session starts
(first review after a gap of SESSION_GAP_MINUTES with no reviews) and
sends one ntfy notification NOTIFY_DELAY_MINUTES after that session
started, if the session is still going (i.e. more reviews happened
after the initial one) — not on every single review.
All configuration (NTFY_URL, NTFY_TOPIC, SESSION_GAP_MINUTES,
NOTIFY_DELAY_MINUTES, ANKI_DATA_DIR, STATE_FILE) comes from environment
variables, set in docker-compose.yml / .env by the installer — nothing to
hand-edit in this file.
"""
import glob
import json
import os
import sqlite3
import threading
import time
from datetime import datetime, timezone
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", "anki-progress")
SESSION_GAP_MINUTES = int(os.environ.get("SESSION_GAP_MINUTES", 30))
NOTIFY_DELAY_MINUTES = int(os.environ.get("NOTIFY_DELAY_MINUTES", 10))
POLL_INTERVAL_SECONDS = 60
ANKI_DATA_DIR = os.environ.get("ANKI_DATA_DIR", "/anki-data")
STATE_FILE = os.environ.get("STATE_FILE", "/app/state/notify_state.json")
app = Flask(__name__)
def find_collections():
"""{username: path-to-collection-file} for every account directory found.
Globs for *.anki2 rather than assuming the exact filename, since that's
an implementation detail of the sync server we shouldn't hardcode."""
result = {}
if not os.path.isdir(ANKI_DATA_DIR):
return result
for entry in sorted(os.listdir(ANKI_DATA_DIR)):
user_dir = os.path.join(ANKI_DATA_DIR, entry)
if not os.path.isdir(user_dir):
continue
matches = glob.glob(os.path.join(user_dir, "*.anki2"))
if matches:
result[entry] = matches[0]
return result
def read_revlog_ids_eases(path):
"""Returns a list of (epoch_ms, ease) tuples sorted by time, read-only.
Opening with mode=ro is what makes this safe to run alongside a live
sync server — it never takes a write lock, so it can't corrupt or
block the account that's actually in use."""
uri = f"file:{path}?mode=ro"
con = sqlite3.connect(uri, uri=True)
try:
rows = con.execute("SELECT id, ease FROM revlog ORDER BY id ASC").fetchall()
except sqlite3.OperationalError:
rows = []
finally:
con.close()
return rows
def compute_stats(revlog_rows, now_ms):
"""Pure function over a list of (epoch_ms, ease) — kept separate from
any file/DB access so it can be unit-tested with synthetic data."""
if not revlog_rows:
return {
"total_reviews": 0, "reviews_today": 0, "reviews_week": 0,
"accuracy_pct": None, "streak_days": 0, "last_active": None,
}
day_ms = 24 * 60 * 60 * 1000
today_day = now_ms // day_ms
today_start = today_day * day_ms
week_start = today_start - 6 * day_ms
reviews_today = sum(1 for ts, _ in revlog_rows if ts >= today_start)
reviews_week = sum(1 for ts, _ in revlog_rows if ts >= week_start)
total = len(revlog_rows)
correct = sum(1 for _, ease in revlog_rows if ease != 1) # ease 1 = "Again" = a miss
accuracy_pct = round(100 * correct / total, 1) if total else None
# Streak: consecutive calendar days with >=1 review, walking backward
# from today. Still "alive" through yesterday if today has no reviews
# yet (so it doesn't reset to 0 first thing each morning) — but not if
# the most recent review is 2+ days old. review_days is unique/sorted
# descending, so any day that isn't exactly "expected" means a gap.
review_days = sorted({ts // day_ms for ts, _ in revlog_rows}, reverse=True)
streak = 0
if review_days and review_days[0] in (today_day, today_day - 1):
expected = review_days[0]
for d in review_days:
if d == expected:
streak += 1
expected -= 1
else:
break
last_active = max(ts for ts, _ in revlog_rows)
return {
"total_reviews": total,
"reviews_today": reviews_today,
"reviews_week": reviews_week,
"accuracy_pct": accuracy_pct,
"streak_days": streak,
"last_active": last_active,
}
def detect_current_session_start(revlog_rows, now_ms):
"""Walk backwards from the most recent review; the session start is the
earliest review such that every gap between consecutive reviews from
there to now is < SESSION_GAP_MINUTES. Returns None if the most recent
review itself is older than the gap threshold (no session "in progress")."""
if not revlog_rows:
return None
gap_ms = SESSION_GAP_MINUTES * 60 * 1000
last_ts = revlog_rows[-1][0]
if now_ms - last_ts > gap_ms:
return None # most recent review is old news, not an active session
session_start = last_ts
for ts, _ in reversed(revlog_rows[:-1]):
if session_start - ts > gap_ms:
break
session_start = ts
return session_start
DASHBOARD_TEMPLATE = """
<!doctype html>
<title>Anki Progress</title>
<meta http-equiv="refresh" content="60">
<style>
body { font-family: Arial, sans-serif; background: #f4f6f8; margin: 0; padding: 24px; }
h1 { color: #333; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.card { background: white; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
.card h2 { margin: 0 0 10px 0; font-size: 20px; }
.stat { display: flex; justify-content: space-between; margin: 4px 0; font-size: 15px; }
.stat b { color: #1c4587; }
.empty { color: #888; font-style: italic; }
</style>
<h1>Anki Progress</h1>
<div class="grid">
{% for user, s in stats.items() %}
<div class="card">
<h2>{{ user }}</h2>
{% if s.total_reviews == 0 %}
<div class="empty">No reviews yet</div>
{% else %}
<div class="stat"><span>Reviews today</span><b>{{ s.reviews_today }}</b></div>
<div class="stat"><span>Reviews this week</span><b>{{ s.reviews_week }}</b></div>
<div class="stat"><span>Accuracy</span><b>{{ s.accuracy_pct }}%</b></div>
<div class="stat"><span>Streak</span><b>{{ s.streak_days }} day{{ 's' if s.streak_days != 1 else '' }}</b></div>
<div class="stat"><span>Last active</span><b>{{ s.last_active_str }}</b></div>
{% endif %}
</div>
{% endfor %}
</div>
"""
@app.route("/")
def dashboard():
now_ms = int(time.time() * 1000)
stats = {}
for user, path in find_collections().items():
rows = read_revlog_ids_eases(path)
s = compute_stats(rows, now_ms)
if s["last_active"]:
s["last_active_str"] = datetime.fromtimestamp(
s["last_active"] / 1000, tz=timezone.utc
).astimezone().strftime("%b %-d, %-I:%M %p")
else:
s["last_active_str"] = "—"
stats[user] = s
return render_template_string(DASHBOARD_TEMPLATE, stats=stats)
def load_notify_state():
if os.path.isfile(STATE_FILE):
with open(STATE_FILE) as f:
return json.load(f)
return {}
def save_notify_state(state):
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as f:
json.dump(state, f)
def send_ntfy(message):
try:
requests.post(f"{NTFY_URL.rstrip('/')}/{NTFY_TOPIC}",
data=message.encode("utf-8"), timeout=10)
except requests.RequestException as e:
print(f"[ntfy] failed to send: {e}")
def notifier_loop():
state = load_notify_state()
while True:
now_ms = int(time.time() * 1000)
for user, path in find_collections().items():
rows = read_revlog_ids_eases(path)
session_start = detect_current_session_start(rows, now_ms)
entry = state.get(user, {})
if session_start is None:
# No active session right now — clear tracking so the next
# real session starts fresh.
if entry:
state[user] = {}
continue
if entry.get("session_start") != session_start:
# A new session started (different from whatever we were
# tracking) — start the countdown over.
state[user] = {"session_start": session_start, "notified": False}
entry = state[user]
elapsed_minutes = (now_ms - session_start) / 60000
if not entry.get("notified") and elapsed_minutes >= NOTIFY_DELAY_MINUTES:
send_ntfy(f"{user} started studying {NOTIFY_DELAY_MINUTES} minutes ago and is still going.")
entry["notified"] = True
save_notify_state(state)
time.sleep(POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
threading.Thread(target=notifier_loop, daemon=True).start()
app.run(host="0.0.0.0", port=5000)
PYEOF
backup_if_exists Dockerfile
cat > Dockerfile << 'DOCKEREOF'
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir flask requests
COPY app.py .
CMD ["python3", "app.py"]
DOCKEREOF
backup_if_exists docker-compose.yml
cat > docker-compose.yml << COMPOSEEOF
name: $CONTAINER
services:
$CONTAINER:
build: .
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
env_file: .env
environment:
- ANKI_DATA_DIR=/anki-data
- STATE_FILE=/app/state/notify_state.json
volumes:
# Read-only — this container only ever reads collection files (see
# app.py's read_revlog_ids_eases, which opens SQLite in mode=ro),
# never writes, so it can't corrupt live data the sync server or a
# client is using.
- $SYNC_DATA_DIR:/anki-data:ro
- ./state:/app/state
ports:
- "${WEB_PORT}:5000"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
COMPOSEEOF
backup_if_exists .env
cat > .env << ENVEOF
NTFY_URL=$NTFY_URL
NTFY_TOPIC=$NTFY_TOPIC
SESSION_GAP_MINUTES=$SESSION_GAP_MINUTES
NOTIFY_DELAY_MINUTES=$NOTIFY_DELAY_MINUTES
ENVEOF
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AP_DIR"
echo ""
log_success "Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $AP_DIR (port $WEB_PORT)"
log_info "Monitoring: $SYNC_INSTANCE"
local START=""
prompt_yn "Start Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d --build \
&& log_success "Anki Progress Dashboard started" \
|| log_warning "Start failed — check: docker compose logs"
fi
# ── Caddy + Authelia-aware protection ────────────────────────────────────
# 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.
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
EXTRA_BLOCK=" import authelia"
log_info "Local Authelia detected — protecting with it."
else
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
local _remote_authelia=""
prompt_text " Remote Authelia address (bare host:port on a private network, or a full https:// URL on its own public domain+TLS):" "" _remote_authelia
if [ -n "$_remote_authelia" ]; then
EXTRA_BLOCK=" forward_auth ${_remote_authelia} {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Method {method}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Uri {uri}
}"
fi
fi
fi
configure_caddy_for_service "Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5000" "anki-progress${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" "$EXTRA_BLOCK"
declare -F _authelia_scope_access >/dev/null 2>&1 && [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ] \
&& _authelia_scope_access "anki-progress" "$CADDY_SERVICE_DOMAIN"
write_readme "$AP_DIR" << MD
# Anki Progress Dashboard${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Read-only study-progress dashboard for the accounts on **$SYNC_INSTANCE**
(reviews today/this week, accuracy, streak, last active), plus an ntfy
notification sent ${NOTIFY_DELAY_MINUTES} minutes after a study session
starts — defined as the first review after ${SESSION_GAP_MINUTES}+ minutes
of inactivity, and only sent if the session is still going at that point
(not on every single review, and not for a session that's already over).
Reads collection files directly with SQLite's read-only mode — never opens
them for write, so it can't corrupt or interfere with the live sync server
or any client actively syncing.
## Access
- URL: $( [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" )
## Config
- \`$AP_DIR/.env\` — ntfy URL/topic, session-gap and notify-delay minutes
- Edit and \`docker compose up -d\` to apply changes (no rebuild needed —
these are read at container start from environment variables)
## Manage
\`\`\`bash
cd $AP_DIR
docker compose up -d --build
docker compose down
docker compose logs -f
\`\`\`
MD
}
# ── Standalone execution ───────────────────────────────────────────────────
if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then
install_anki-progress
fi
+420
View File
@@ -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": """
<div class="problem">
<div class="line1">{{Top}}</div>
<div class="line2">{{Bottom}}</div>
<div class="rule"></div>
</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="problem">
<div class="line1">{{Top}}</div>
<div class="line2">{{Bottom}}</div>
<div class="rule"></div>
</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 28px; text-align: center; }
.problem { display: inline-block; text-align: right; margin: 20px auto; }
.line1, .line2 { font-size: 48px; padding: 2px 10px; }
.rule { border-top: 3px solid black; margin-top: 4px; width: 100%; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_000_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, top, bottom, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[top, bottom, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
# ─── Per-deck generators ─────────────────────────────────────────────────────
def gen_multiplication():
deck_key = "multiplication"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Multiplication Facts (1-12)")
media_files = []
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
random.seed(42)
random.shuffle(pairs)
for a, b in pairs:
ans = a * b
add_note(deck, model, str(a), f"&times; {b}", str(ans),
f"{num2words(a)} times {num2words(b)}", num2words(ans),
media_files, f"mul_{a}_{b}")
return deck, media_files, len(pairs)
def gen_division():
deck_key = "division"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Division Facts (inverse of 1-12 times tables)")
media_files = []
# Same (a, b) pairs as multiplication: product / a = b. This is the
# direct inverse of every multiplication card in that deck.
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
random.seed(43)
random.shuffle(pairs)
for a, b in pairs:
product = a * b
add_note(deck, model, str(product), f"&divide; {a}", str(b),
f"{num2words(product)} divided by {num2words(a)}", num2words(b),
media_files, f"div_{a}_{b}")
return deck, media_files, len(pairs)
def gen_addsub(lo, hi):
deck_key = f"addsub_{lo}_{hi}"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, f"Addition & Subtraction Facts ({lo}-{hi})")
media_files = []
add_pairs = [(a, b) for a in range(lo, hi + 1) for b in range(lo, hi + 1)]
random.seed(hash((lo, hi)) & 0xFFFFFFFF)
random.shuffle(add_pairs)
sub_facts = [] # (minuend, subtrahend, answer)
seen = set()
for a, b in add_pairs:
c = a + b
for minuend, subtrahend, answer in ((c, a, b), (c, b, a)):
key = (minuend, subtrahend)
if key not in seen:
seen.add(key)
sub_facts.append((minuend, subtrahend, answer))
random.shuffle(sub_facts)
count = 0
for a, b in add_pairs:
ans = a + b
add_note(deck, model, str(a), f"+ {b}", str(ans),
f"{num2words(a)} plus {num2words(b)}", num2words(ans),
media_files, f"add_{lo}_{hi}_{a}_{b}")
count += 1
for minuend, subtrahend, answer in sub_facts:
add_note(deck, model, str(minuend), f"&minus; {subtrahend}", str(answer),
f"{num2words(minuend)} minus {num2words(subtrahend)}", num2words(answer),
media_files, f"sub_{lo}_{hi}_{minuend}_{subtrahend}")
count += 1
return deck, media_files, count
def gen_fractions():
deck_key = "fractions"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Reducing Fractions to Lowest Terms")
media_files = []
facts = []
for den in range(2, 13):
for num in range(1, den):
g = math.gcd(num, den)
if g > 1:
facts.append((num, den, num // g, den // g))
random.seed(44)
random.shuffle(facts)
for num, den, rnum, rden in facts:
answer = f"{rnum}/{rden}"
add_note(deck, model, str(num), f"&frasl; {den}", answer,
fraction_words(num, den), fraction_words(rnum, rden),
media_files, f"frac_{num}_{den}")
return deck, media_files, len(facts)
def gen_decimals():
deck_key = "decimals"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Fraction to Decimal Conversion")
media_files = []
# Only denominators whose only prime factors are 2 and 5 terminate in a
# finite decimal (1/3 = 0.333... never terminates) — restricting to
# these avoids ever needing to round/repeat.
facts = []
for den in (2, 4, 5, 8, 10, 20, 25):
for num in range(1, den):
if math.gcd(num, den) != 1:
continue # skip non-lowest-terms fractions (already covered by the fractions deck)
value = num / den
decimal_str = f"{value:.10f}".rstrip("0")
if decimal_str.endswith("."):
decimal_str += "0"
facts.append((num, den, decimal_str))
random.seed(45)
random.shuffle(facts)
for num, den, decimal_str in facts:
add_note(deck, model, str(num), f"&frasl; {den}", decimal_str,
fraction_words(num, den), decimal_words(decimal_str),
media_files, f"dec_{num}_{den}")
return deck, media_files, len(facts)
# ─── Dispatch ─────────────────────────────────────────────────────────────────
if args.deck == "addsub":
deck_key = f"addsub_{args.lo}_{args.hi}"
else:
deck_key = args.deck
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {
"multiplication": lambda: gen_multiplication(),
"division": lambda: gen_division(),
"addsub": lambda: gen_addsub(args.lo, args.hi),
"fractions": lambda: gen_fractions(),
"decimals": lambda: gen_decimals(),
}
deck, media_files, count = GENERATORS[args.deck]()
if count == 0:
raise SystemExit(f"No cards generated for --deck {args.deck} — check the range/args.")
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""tools/anki-deck-periodic.py — Generate periodic table Anki decks (.apkg)
with Anki's built-in type-the-answer input and Piper (offline, local
neural TTS) audio on both sides. See tools/anki-deck-math.py's docstring
for one-time setup (venv, genanki + piper-tts, downloading a voice) — same
steps apply here, this is a standalone, self-contained script otherwise.
Decks:
prehs symbol<->name, elements 1-36 (H through Kr)
hs symbol<->name plus number->symbol, all 118 elements
category element category as multiple choice (A/B/C/D shown as
plain text options — not a clickable UI, since that needs
a desktop-only Anki add-on and would break on
AnkiDroid/AnkiMobile), type the letter — only elements
with a confirmed category (excludes 8 very recent
superheavy elements whose category is still officially
unconfirmed)
Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively
maintained public dataset), fetched and spot-checked against known facts
before being embedded below — not typed from memory.
Usage (run with the venv from anki-deck-math.py's docstring activated):
python3 anki-deck-periodic.py --deck prehs
python3 anki-deck-periodic.py --deck hs
python3 anki-deck-periodic.py --deck category
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
automatically; --dry-run-tts to test the deck-building logic without any
voice model at all, using silent placeholder audio)
"""
import argparse
import hashlib
import genanki
import os
import random
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"])
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")
args = parser.parse_args()
SCRATCH = os.path.dirname(os.path.abspath(__file__))
_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:
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,
)
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 < 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 "")
assert num2words(1) == "one"
assert num2words(26) == "twenty-six"
assert num2words(118) == "one hundred eighteen"
# ─── Element data: (atomic_number, symbol, name, category-or-None) ─────────
# category is None for the 8 most recently synthesized superheavy elements
# whose chemical category is still officially unconfirmed (excluded from
# the category deck below, still included in prehs/hs symbol/name/number).
ELEMENTS = [
(1, 'H', 'Hydrogen', 'diatomic nonmetal'),
(2, 'He', 'Helium', 'noble gas'),
(3, 'Li', 'Lithium', 'alkali metal'),
(4, 'Be', 'Beryllium', 'alkaline earth metal'),
(5, 'B', 'Boron', 'metalloid'),
(6, 'C', 'Carbon', 'polyatomic nonmetal'),
(7, 'N', 'Nitrogen', 'diatomic nonmetal'),
(8, 'O', 'Oxygen', 'diatomic nonmetal'),
(9, 'F', 'Fluorine', 'diatomic nonmetal'),
(10, 'Ne', 'Neon', 'noble gas'),
(11, 'Na', 'Sodium', 'alkali metal'),
(12, 'Mg', 'Magnesium', 'alkaline earth metal'),
(13, 'Al', 'Aluminium', 'post-transition metal'),
(14, 'Si', 'Silicon', 'metalloid'),
(15, 'P', 'Phosphorus', 'polyatomic nonmetal'),
(16, 'S', 'Sulfur', 'polyatomic nonmetal'),
(17, 'Cl', 'Chlorine', 'diatomic nonmetal'),
(18, 'Ar', 'Argon', 'noble gas'),
(19, 'K', 'Potassium', 'alkali metal'),
(20, 'Ca', 'Calcium', 'alkaline earth metal'),
(21, 'Sc', 'Scandium', 'transition metal'),
(22, 'Ti', 'Titanium', 'transition metal'),
(23, 'V', 'Vanadium', 'transition metal'),
(24, 'Cr', 'Chromium', 'transition metal'),
(25, 'Mn', 'Manganese', 'transition metal'),
(26, 'Fe', 'Iron', 'transition metal'),
(27, 'Co', 'Cobalt', 'transition metal'),
(28, 'Ni', 'Nickel', 'transition metal'),
(29, 'Cu', 'Copper', 'transition metal'),
(30, 'Zn', 'Zinc', 'transition metal'),
(31, 'Ga', 'Gallium', 'post-transition metal'),
(32, 'Ge', 'Germanium', 'metalloid'),
(33, 'As', 'Arsenic', 'metalloid'),
(34, 'Se', 'Selenium', 'polyatomic nonmetal'),
(35, 'Br', 'Bromine', 'diatomic nonmetal'),
(36, 'Kr', 'Krypton', 'noble gas'),
(37, 'Rb', 'Rubidium', 'alkali metal'),
(38, 'Sr', 'Strontium', 'alkaline earth metal'),
(39, 'Y', 'Yttrium', 'transition metal'),
(40, 'Zr', 'Zirconium', 'transition metal'),
(41, 'Nb', 'Niobium', 'transition metal'),
(42, 'Mo', 'Molybdenum', 'transition metal'),
(43, 'Tc', 'Technetium', 'transition metal'),
(44, 'Ru', 'Ruthenium', 'transition metal'),
(45, 'Rh', 'Rhodium', 'transition metal'),
(46, 'Pd', 'Palladium', 'transition metal'),
(47, 'Ag', 'Silver', 'transition metal'),
(48, 'Cd', 'Cadmium', 'transition metal'),
(49, 'In', 'Indium', 'post-transition metal'),
(50, 'Sn', 'Tin', 'post-transition metal'),
(51, 'Sb', 'Antimony', 'metalloid'),
(52, 'Te', 'Tellurium', 'metalloid'),
(53, 'I', 'Iodine', 'diatomic nonmetal'),
(54, 'Xe', 'Xenon', 'noble gas'),
(55, 'Cs', 'Cesium', 'alkali metal'),
(56, 'Ba', 'Barium', 'alkaline earth metal'),
(57, 'La', 'Lanthanum', 'lanthanide'),
(58, 'Ce', 'Cerium', 'lanthanide'),
(59, 'Pr', 'Praseodymium', 'lanthanide'),
(60, 'Nd', 'Neodymium', 'lanthanide'),
(61, 'Pm', 'Promethium', 'lanthanide'),
(62, 'Sm', 'Samarium', 'lanthanide'),
(63, 'Eu', 'Europium', 'lanthanide'),
(64, 'Gd', 'Gadolinium', 'lanthanide'),
(65, 'Tb', 'Terbium', 'lanthanide'),
(66, 'Dy', 'Dysprosium', 'lanthanide'),
(67, 'Ho', 'Holmium', 'lanthanide'),
(68, 'Er', 'Erbium', 'lanthanide'),
(69, 'Tm', 'Thulium', 'lanthanide'),
(70, 'Yb', 'Ytterbium', 'lanthanide'),
(71, 'Lu', 'Lutetium', 'lanthanide'),
(72, 'Hf', 'Hafnium', 'transition metal'),
(73, 'Ta', 'Tantalum', 'transition metal'),
(74, 'W', 'Tungsten', 'transition metal'),
(75, 'Re', 'Rhenium', 'transition metal'),
(76, 'Os', 'Osmium', 'transition metal'),
(77, 'Ir', 'Iridium', 'transition metal'),
(78, 'Pt', 'Platinum', 'transition metal'),
(79, 'Au', 'Gold', 'transition metal'),
(80, 'Hg', 'Mercury', 'transition metal'),
(81, 'Tl', 'Thallium', 'post-transition metal'),
(82, 'Pb', 'Lead', 'post-transition metal'),
(83, 'Bi', 'Bismuth', 'post-transition metal'),
(84, 'Po', 'Polonium', 'post-transition metal'),
(85, 'At', 'Astatine', 'diatomic nonmetal'),
(86, 'Rn', 'Radon', 'noble gas'),
(87, 'Fr', 'Francium', 'alkali metal'),
(88, 'Ra', 'Radium', 'alkaline earth metal'),
(89, 'Ac', 'Actinium', 'actinide'),
(90, 'Th', 'Thorium', 'actinide'),
(91, 'Pa', 'Protactinium', 'actinide'),
(92, 'U', 'Uranium', 'actinide'),
(93, 'Np', 'Neptunium', 'actinide'),
(94, 'Pu', 'Plutonium', 'actinide'),
(95, 'Am', 'Americium', 'actinide'),
(96, 'Cm', 'Curium', 'actinide'),
(97, 'Bk', 'Berkelium', 'actinide'),
(98, 'Cf', 'Californium', 'actinide'),
(99, 'Es', 'Einsteinium', 'actinide'),
(100, 'Fm', 'Fermium', 'actinide'),
(101, 'Md', 'Mendelevium', 'actinide'),
(102, 'No', 'Nobelium', 'actinide'),
(103, 'Lr', 'Lawrencium', 'actinide'),
(104, 'Rf', 'Rutherfordium', 'transition metal'),
(105, 'Db', 'Dubnium', 'transition metal'),
(106, 'Sg', 'Seaborgium', 'transition metal'),
(107, 'Bh', 'Bohrium', 'transition metal'),
(108, 'Hs', 'Hassium', 'transition metal'),
(109, 'Mt', 'Meitnerium', None),
(110, 'Ds', 'Darmstadtium', None),
(111, 'Rg', 'Roentgenium', None),
(112, 'Cn', 'Copernicium', None),
(113, 'Nh', 'Nihonium', 'post-transition metal'),
(114, 'Fl', 'Flerovium', 'post-transition metal'),
(115, 'Mc', 'Moscovium', None),
(116, 'Lv', 'Livermorium', None),
(117, 'Ts', 'Tennessine', None),
(118, 'Og', 'Oganesson', None),
]
assert len(ELEMENTS) == 118
assert [e[0] for e in ELEMENTS] == list(range(1, 119))
assert ELEMENTS[0] == (1, 'H', 'Hydrogen', 'diatomic nonmetal')
assert ELEMENTS[25] == (26, 'Fe', 'Iron', 'transition metal')
assert ELEMENTS[-1] == (118, 'Og', 'Oganesson', None)
ALL_CATEGORIES = sorted({e[3] for e in ELEMENTS if e[3] is not None})
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_700_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Periodic Table ({deck_key}, {args.voice})",
fields=[{"name": "Prompt"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="prompt">{{Prompt}}</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="prompt">{{Prompt}}</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; }
.prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_100_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[prompt, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
def gen_prehs():
deck_key = "periodic_prehs"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Symbols & Names (1-36)")
media_files = []
subset = [e for e in ELEMENTS if e[0] <= 36]
cards = []
for number, symbol, name, category in subset:
cards.append(("symbol_to_name", number, symbol, name))
cards.append(("name_to_symbol", number, symbol, name))
random.seed(50)
random.shuffle(cards)
for kind, number, symbol, name in cards:
if kind == "symbol_to_name":
add_note(deck, model, symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"prehs_s2n_{number}")
else:
add_note(deck, model, name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"prehs_n2s_{number}")
return deck, media_files, len(cards)
def gen_hs():
deck_key = "periodic_hs"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Symbols, Names & Numbers (1-118)")
media_files = []
cards = []
for number, symbol, name, category in ELEMENTS:
cards.append(("symbol_to_name", number, symbol, name))
cards.append(("name_to_symbol", number, symbol, name))
cards.append(("number_to_symbol", number, symbol, name))
random.seed(51)
random.shuffle(cards)
for kind, number, symbol, name in cards:
if kind == "symbol_to_name":
add_note(deck, model, symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"hs_s2n_{number}")
elif kind == "name_to_symbol":
add_note(deck, model, name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"hs_n2s_{number}")
else:
add_note(deck, model, f"Element #{number}", symbol,
f"What is the symbol for element number {num2words(number)}?", symbol,
media_files, f"hs_num2s_{number}")
return deck, media_files, len(cards)
def gen_category():
deck_key = "periodic_category"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Element Categories (multiple choice)")
media_files = []
subset = [e for e in ELEMENTS if e[3] is not None]
random.seed(52)
shuffled = subset[:]
random.shuffle(shuffled)
letters = ["A", "B", "C", "D"]
for number, symbol, name, category in shuffled:
distractor_pool = [c for c in ALL_CATEGORIES if c != category]
distractors = random.sample(distractor_pool, 3)
choices = distractors + [category]
random.shuffle(choices)
correct_letter = letters[choices.index(category)]
prompt_lines = [f"{name} ({symbol})", ""]
for letter, choice in zip(letters, choices):
prompt_lines.append(f"{letter}) {choice}")
prompt = "\n".join(prompt_lines)
qtext = f"What category is {name}?"
atext = f"{category}"
add_note(deck, model, prompt, correct_letter, qtext, atext,
media_files, f"cat_{number}")
return deck, media_files, len(subset)
if args.deck == "prehs":
deck_key = "periodic_prehs"
elif args.deck == "hs":
deck_key = "periodic_hs"
else:
deck_key = "periodic_category"
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {"prehs": gen_prehs, "hs": gen_hs, "category": gen_category}
deck, media_files, count = GENERATORS[args.deck]()
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""tools/anki-deck-visual.py — Generate image-based Anki decks (.apkg) for
shapes, clocks, and coin-counting, with Anki's built-in type-the-answer
input and Piper (offline, local neural TTS) audio. See
tools/anki-deck-math.py's docstring for one-time setup (venv, genanki +
piper-tts, downloading a voice) — same steps apply here.
All images are drawn programmatically as SVG (regular-polygon geometry,
clock-hand trigonometry, coin layouts) rather than AI-generated — image
generation (local or cloud) is a poor fit for content that has to be
exactly correct (an exact clock time, an exact side count, an exact coin
total), not just plausible-looking. See this script's own point/angle
generation functions for how each shape's geometry is computed directly
rather than approximated.
Decks:
shapes regular polygons (3-10 sides, image->name and name->sides)
plus 5 quadrilateral types (image->name: square, rectangle,
rhombus, trapezoid, parallelogram — each one's geometry is
genuinely distinct, not just differently labeled)
clocks analog clock faces, all 144 hour/5-min combinations,
type the time as H:MM (the hour hand moves fractionally
with the minutes, e.g. 6:30 sits halfway between 6 and 7 —
a static hour hand is the most common "looks right but
teaches wrong" bug in generated clock faces)
currency US coins (nickel/dime/quarter — no pennies, since they're
barely used day to day at this point), 1-4 coins per card,
type the total in cents
Usage (run with the venv from anki-deck-math.py's docstring activated):
python3 anki-deck-visual.py --deck shapes
python3 anki-deck-visual.py --deck clocks
python3 anki-deck-visual.py --deck currency
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
automatically; --dry-run-tts to test the deck-building logic without any
voice model at all, using silent placeholder audio)
"""
import argparse
import hashlib
import genanki
import math
import os
import random
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True, choices=["shapes", "clocks", "currency"])
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")
args = parser.parse_args()
SCRATCH = os.path.dirname(os.path.abspath(__file__))
_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:
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,
)
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 < 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 "")
assert num2words(15) == "fifteen"
assert num2words(40) == "forty"
def time_words(hour, minute):
"""3, 5 -> 'three oh five'; 3, 15 -> 'three fifteen'; 3, 0 -> 'three o'clock'."""
if minute == 0:
return f"{num2words(hour)} o'clock"
if minute < 10:
return f"{num2words(hour)} oh {num2words(minute)}"
return f"{num2words(hour)} {num2words(minute)}"
assert time_words(3, 0) == "three o'clock"
assert time_words(3, 5) == "three oh five"
assert time_words(3, 15) == "three fifteen"
assert time_words(12, 45) == "twelve forty-five"
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_800_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Visual Fact ({deck_key}, {args.voice})",
fields=[{"name": "Image"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="imgwrap">{{Image}}</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="imgwrap">{{Image}}</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; }
.imgwrap { margin: 10px auto; }
.imgwrap img { max-width: 260px; max-height: 260px; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_200_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, image_html, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[image_html, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
def add_text_note(deck, model, text, answer, qtext, atext, media_files, tag):
"""For directions that don't need an image (e.g. name -> number of sides)."""
add_note(deck, model, f'<div style="font-size:36px;">{text}</div>', answer,
qtext, atext, media_files, tag)
# ─── SVG generation ───────────────────────────────────────────────────────────
def save_svg(svg_body, filename, viewbox="0 0 200 200"):
path = os.path.join(MEDIA_DIR, filename)
with open(path, "w") as f:
f.write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{viewbox}" '
f'width="200" height="200">{svg_body}</svg>'
)
return path
def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
points = []
# Start pointing up (-90deg) so shapes sit "upright" rather than vertex-right.
start_angle = -90
for i in range(n_sides):
angle_deg = start_angle + i * (360 / n_sides)
angle_rad = math.radians(angle_deg)
x = cx + r * math.cos(angle_rad)
y = cy + r * math.sin(angle_rad)
points.append((round(x, 1), round(y, 1)))
return points
def polygon_svg(points):
pts_str = " ".join(f"{x},{y}" for x, y in points)
return f'<polygon points="{pts_str}" fill="#6fa8dc" stroke="#1c4587" stroke-width="4"/>'
POLYGON_NAMES = {
3: "triangle", 4: "square", 5: "pentagon", 6: "hexagon", 7: "heptagon",
8: "octagon", 9: "nonagon", 10: "decagon",
}
QUADRILATERALS = {
"square": [(50, 50), (150, 50), (150, 150), (50, 150)],
"rectangle": [(30, 60), (170, 60), (170, 140), (30, 140)],
"rhombus": [(100, 20), (170, 100), (100, 180), (30, 100)],
"trapezoid": [(60, 60), (140, 60), (170, 140), (30, 140)],
"parallelogram": [(60, 60), (160, 60), (140, 140), (40, 140)],
}
def clock_svg(hour, minute):
cx, cy, r = 100, 100, 90
minute_angle = minute * 6 - 90
hour_angle = (hour % 12) * 30 + minute * 0.5 - 90
def hand(angle_deg, length, width, color):
rad = math.radians(angle_deg)
x2 = cx + length * math.cos(rad)
y2 = cy + length * math.sin(rad)
return f'<line x1="{cx}" y1="{cy}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{color}" stroke-width="{width}" stroke-linecap="round"/>'
ticks = []
numerals = []
for h in range(1, 13):
angle = math.radians(h * 30 - 90)
tx1, ty1 = cx + (r - 10) * math.cos(angle), cy + (r - 10) * math.sin(angle)
tx2, ty2 = cx + r * math.cos(angle), cy + r * math.sin(angle)
ticks.append(f'<line x1="{tx1:.1f}" y1="{ty1:.1f}" x2="{tx2:.1f}" y2="{ty2:.1f}" stroke="black" stroke-width="2"/>')
nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
numerals.append(f'<text x="{nx:.1f}" y="{ny:.1f}" font-size="14" text-anchor="middle" dominant-baseline="middle">{h}</text>')
body = (
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="white" stroke="black" stroke-width="3"/>'
+ "".join(ticks) + "".join(numerals)
+ hand(hour_angle, 45, 6, "black")
+ hand(minute_angle, 70, 4, "black")
+ f'<circle cx="{cx}" cy="{cy}" r="4" fill="black"/>'
)
return body
COIN_INFO = {5: ("#c0c0c0", ""), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")}
COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"}
def coins_svg(coin_values):
n = len(coin_values)
spacing = 200 // (n + 1)
parts = []
for i, v in enumerate(coin_values):
cx = spacing * (i + 1)
color, label = COIN_INFO[v]
radius = 30 if v == 25 else (26 if v == 10 else 28)
parts.append(
f'<circle cx="{cx}" cy="100" r="{radius}" fill="{color}" stroke="#444" stroke-width="2"/>'
f'<text x="{cx}" y="105" font-size="14" text-anchor="middle">{label}</text>'
)
return "".join(parts)
def coin_list_words(coin_values):
names = [COIN_NAMES[v] for v in coin_values]
if len(names) == 1:
return f"a {names[0]}"
if len(names) == 2:
return f"a {names[0]} and a {names[1]}"
return ", ".join(f"a {n}" for n in names[:-1]) + f", and a {names[-1]}"
# ─── Per-deck generators ─────────────────────────────────────────────────────
def gen_shapes():
deck_key = "shapes"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals")
media_files = []
jobs = []
for n in range(3, 11):
jobs.append(("polygon_image", n))
jobs.append(("polygon_sides", n))
for qname in QUADRILATERALS:
jobs.append(("quad_image", qname))
random.seed(60)
random.shuffle(jobs)
for kind, val in jobs:
if kind == "polygon_image":
n = val
name = POLYGON_NAMES[n]
svg_path = save_svg(polygon_svg(regular_polygon_points(n)), f"poly_{n}.svg")
media_files.append(svg_path)
add_note(deck, model, f'<img src="poly_{n}.svg">', name,
"What shape is this?", name, media_files, f"shape_img_{n}")
elif kind == "polygon_sides":
n = val
name = POLYGON_NAMES[n]
add_text_note(deck, model, name.capitalize(), str(n),
f"How many sides does a {name} have?", num2words(n),
media_files, f"shape_sides_{n}")
else:
qname = val
svg_path = save_svg(polygon_svg(QUADRILATERALS[qname]), f"quad_{qname}.svg")
media_files.append(svg_path)
add_note(deck, model, f'<img src="quad_{qname}.svg">', qname,
"What shape is this?", qname, media_files, f"shape_quad_{qname}")
return deck, media_files, len(jobs)
def gen_clocks():
deck_key = "clocks"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Telling Time: Analog Clocks")
media_files = []
times = [(h, m) for h in range(1, 13) for m in range(0, 60, 5)]
random.seed(61)
random.shuffle(times)
# The question prompt ("What time is it?") is identical for every card —
# generate it once instead of 144 times.
shared_qfile = "q_clock_prompt.wav"
piper_tts("What time is it?", os.path.join(MEDIA_DIR, shared_qfile))
media_files.append(os.path.join(MEDIA_DIR, shared_qfile))
for hour, minute in times:
svg_path = save_svg(clock_svg(hour, minute), f"clock_{hour}_{minute:02d}.svg")
media_files.append(svg_path)
answer = f"{hour}:{minute:02d}"
afile = f"a_clock_{hour}_{minute:02d}.wav"
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(time_words(hour, minute), apath)
media_files.append(apath)
deck.add_note(genanki.Note(
model=model,
fields=[f'<img src="clock_{hour}_{minute:02d}.svg">', answer,
f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
))
return deck, media_files, len(times)
def gen_currency():
deck_key = "currency"
model_id, model = build_model(deck_key)
# Deliberately nickel/dime/quarter only, no pennies — pennies are barely
# used day to day at this point, and skipping them keeps every total a
# multiple of 5 cents, which is a cleaner first pass at coin counting.
deck = build_deck(deck_key, "Counting Coins (nickels, dimes, quarters)")
media_files = []
denoms = [5, 10, 25]
combos = set()
for count in range(1, 5):
def rec(remaining, current):
if remaining == 0:
combos.add(tuple(sorted(current)))
return
for d in denoms:
if not current or d >= current[-1]:
rec(remaining - 1, current + [d])
rec(count, [])
combos = sorted(combos)
random.seed(62)
random.shuffle(combos)
for coin_values in combos:
total = sum(coin_values)
svg_path = save_svg(coins_svg(list(coin_values)), f"coins_{'_'.join(map(str, coin_values))}.svg")
media_files.append(svg_path)
qtext = f"How much money is {coin_list_words(list(coin_values))}?"
atext = f"{num2words(total)} cents"
add_note(deck, model, f'<img src="coins_{"_".join(map(str, coin_values))}.svg">',
str(total), qtext, atext, media_files, f"coins_{'_'.join(map(str, coin_values))}")
return deck, media_files, len(combos)
if args.deck == "shapes":
deck_key = "shapes"
elif args.deck == "clocks":
deck_key = "clocks"
else:
deck_key = "currency"
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {"shapes": gen_shapes, "clocks": gen_clocks, "currency": gen_currency}
deck, media_files, count = GENERATORS[args.deck]()
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")