#!/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