diff --git a/README.md b/README.md index f9409e8..12abe8f 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-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 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) | | `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` | diff --git a/services/anki-progress.sh b/services/anki-progress.sh new file mode 100644 index 0000000..f34e839 --- /dev/null +++ b/services/anki-progress.sh @@ -0,0 +1,741 @@ +#!/bin/bash +# services/anki-progress.sh — Family 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 "Family 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(-) 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:" "family-anki" 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 +"""Family Anki 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", "family-anki") + +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 = """ + +Family Anki Progress + + +

Family Anki Progress

+
+ {% for user, s in stats.items() %} +
+

{{ user }}

+ {% if s.total_reviews == 0 %} +
No reviews yet
+ {% else %} +
Reviews today{{ s.reviews_today }}
+
Reviews this week{{ s.reviews_week }}
+
Accuracy{{ s.accuracy_pct }}%
+
Streak{{ s.streak_days }} day{{ 's' if s.streak_days != 1 else '' }}
+
Last active{{ s.last_active_str }}
+ {% endif %} +
+ {% endfor %} +
+""" + + +@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 family member'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 family" + log_warning "member's personal study activity — recommend protecting it" + log_warning "before 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