Files
ubuntu-based-kiosk/menus/addon_anki_sync.sh
T
Claude 75baab8914 Add Anki Sync Server addon (self-hosted flashcard sync)
Installs the official self-hosted anki-sync-server (python -m
anki.syncserver from PyPI's anki package, no Qt/GUI deps) as a
systemd service under $KIOSK_USER, same shape as the Web UI addon:
venv under $ANKI_SYNC_DIR, EnvironmentFile under $ANKI_SYNC_ENV_DIR
(chmod 600 since it holds plaintext sync passwords), multi-user
support via SYNC_USER1/2/... Lets Anki desktop/AnkiDroid clients on
the LAN sync flashcards against this kiosk instead of AnkiWeb.

Added to the existing Addons menu rather than a new top-level
category - Addons is already a flat mix of unrelated self-hosted
services (printing, telephony, media server, remote access), so one
more service fits that pattern instead of fragmenting the menu
structure for a single item.

Wired into complete_uninstall.sh, clone_settings.sh's addon
detection/checklist, and the webui helper script's source list (which
documents itself as mirroring install.sh's sources exactly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoqDrsMq2dazAwGU1AodNc
2026-09-09 13:47:43 +00:00

303 lines
11 KiB
Bash

#!/bin/bash
################################################################################
# menus/addon_anki_sync.sh - "Anki Sync Server" addon.
#
# Self-hosted flashcard sync, as an alternative to AnkiWeb - lets Anki
# desktop and AnkiDroid clients on the LAN sync their collection (any
# note type: simple vocab/math-fact cards or complex custom templates)
# against this kiosk instead of ankiweb.net. Standard Anki-format decks
# import/export as .apkg from the desktop client; Quizlet sets round-trip
# through Anki via tab-separated text (Quizlet's own export/import), so
# this server doesn't need to know anything about that format itself -
# it only syncs whatever collection the client already has.
#
# Installed the officially documented way (docs.ankiweb.net/sync-server.html):
# the real "anki" package from PyPI, run headless as `python -m
# anki.syncserver` - no Qt/GUI deps, since that module never imports the
# desktop app's aqt package. Runs in its own venv under $ANKI_SYNC_DIR
# (lib/config.sh) as $KIOSK_USER, same shape as menus/addon_webui.sh's
# Node app: an EnvironmentFile under $ANKI_SYNC_ENV_DIR feeding a
# systemd unit's ExecStart, both parameterized so tests can point them
# at a scratch directory instead of the real system.
#
# Unlike webui.env, anki-sync.env holds plaintext sync passwords
# (SYNC_USER1=user:pass, ... - the only form the upstream server
# accepts), so it's written chmod 600/root:root rather than left at the
# default `sudo tee` mode - systemd reads EnvironmentFile as root before
# dropping to User=$KIOSK_USER, so the service never needs read access
# to it itself.
#
# No login/HTTPS beyond that account+password, same caveat as every
# other plain self-hosted addon here (CUPS, LMS, Web UI): fine on a
# trusted LAN, put it behind a reverse proxy with TLS if it needs to be
# reachable beyond one.
#
# Depends on: lib/menu.sh, lib/config.sh being sourced first.
################################################################################
anki_sync_is_installed() {
[[ -f "$SYSTEMD_DIR/kiosk-anki-sync.service" ]]
}
anki_sync_is_active() {
systemctl is-active --quiet kiosk-anki-sync
}
anki_sync_env_file() {
echo "$ANKI_SYNC_ENV_DIR/anki-sync.env"
}
anki_sync_port() {
sudo grep -oP '^SYNC_PORT=\K.*' "$(anki_sync_env_file)" 2>/dev/null || echo "8080"
}
# No `|| echo 0` here: `grep -c` on a file with zero matches still
# prints "0" to stdout while exiting non-zero, so chaining `||` would
# run *both* branches and return "0\n0" - the pattern menus/
# addon_webui.sh's webui_port() uses safely only works because `-oP`
# prints nothing at all on no match.
anki_sync_user_count() {
local count
count=$(sudo grep -c '^SYNC_USER[0-9]\+=' "$(anki_sync_env_file)" 2>/dev/null)
echo "${count:-0}"
}
addon_anki_sync_status() {
if anki_sync_is_installed && anki_sync_is_active; then
echo "Anki Sync Server: running at http://$(get_ip_address):$(anki_sync_port)/"
echo " Sync users: $(anki_sync_user_count)"
elif anki_sync_is_installed; then
echo "Anki Sync Server: installed but not running"
else
echo "Anki Sync Server: not installed"
fi
}
addon_anki_sync_menu_builder() {
if anki_sync_is_installed; then
MENU_LABELS=("Add another sync user" "Change port" "Restart service" "Uninstall")
MENU_HANDLERS=(action_anki_sync_add_user action_anki_sync_reconfigure_port action_anki_sync_restart action_anki_sync_uninstall)
else
MENU_LABELS=("Install Anki Sync Server")
MENU_HANDLERS=(action_anki_sync_install)
fi
}
addon_anki_sync_menu() {
run_menu "ANKI SYNC SERVER" addon_anki_sync_menu_builder addon_anki_sync_status
}
################################################################################
# Shared install helpers
################################################################################
anki_sync_ask_username() {
local default="$1"
local uname
while true; do
uname=$(ask_text "Username" "$default")
if [[ -z "$uname" ]]; then
echo "❌ Username cannot be empty" >&2
continue
fi
if [[ "$uname" == *:* ]]; then
echo "❌ Username cannot contain ':'" >&2
continue
fi
echo "$uname"
return 0
done
}
# Prompts twice (masked) and returns the password once both entries
# match - same shape as menus/lockout.sh's prompt_and_hash_password,
# but returned as plain text (not hashed): the upstream sync server
# authenticates against the plaintext SYNC_USERn value itself.
anki_sync_ask_password() {
local pass1 pass2
while true; do
read -r -s -p "Password for $1: " pass1
echo
read -r -s -p "Confirm password: " pass2
echo
if [[ -z "$pass1" ]]; then
echo "❌ Password cannot be empty"
continue
fi
if [[ "$pass1" != "$pass2" ]]; then
echo "❌ Passwords do not match"
continue
fi
echo "$pass1"
return 0
done
}
anki_sync_write_base_env() {
local port="$1"
sudo mkdir -p "$ANKI_SYNC_ENV_DIR"
sudo tee "$(anki_sync_env_file)" > /dev/null <<EOF
SYNC_BASE=${ANKI_SYNC_DIR}/data
SYNC_HOST=0.0.0.0
SYNC_PORT=${port}
EOF
sudo chown root:root "$(anki_sync_env_file)"
sudo chmod 600 "$(anki_sync_env_file)"
}
anki_sync_append_user_line() {
local index="$1" username="$2" password="$3"
echo "SYNC_USER${index}=${username}:${password}" | sudo tee -a "$(anki_sync_env_file)" > /dev/null
}
anki_sync_write_unit_file() {
sudo tee "$SYSTEMD_DIR/kiosk-anki-sync.service" > /dev/null <<EOF
[Unit]
Description=Anki Sync Server
After=network.target
[Service]
Type=simple
User=${KIOSK_USER}
EnvironmentFile=${ANKI_SYNC_ENV_DIR}/anki-sync.env
ExecStart=${ANKI_SYNC_DIR}/bin/python -m anki.syncserver
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
}
################################################################################
# Actions
################################################################################
action_anki_sync_install() {
echo
echo "This installs a self-hosted Anki sync server (the official"
echo "'python -m anki.syncserver', from the 'anki' package on PyPI)"
echo "so Anki desktop / AnkiDroid on your LAN can sync flashcards to"
echo "this kiosk instead of AnkiWeb. Plain vocab/math-fact cards and"
echo "complex custom note types both sync fine - it just stores"
echo "whatever collection the client already has. Quizlet decks"
echo "still need to go through Anki's own tab-separated import first"
echo "(File > Import in the desktop app) - this server only syncs"
echo "the resulting collection."
echo
ask_yes_no "Install the Anki Sync Server?" "n" || { echo "Cancelled"; return; }
echo "Installing python3-venv..."
if ! sudo apt-get update -qq || ! sudo apt-get install -y python3-venv python3-pip; then
log_error "Failed to install python3-venv/python3-pip"
return 1
fi
echo "Creating virtual environment..."
sudo mkdir -p "$ANKI_SYNC_DIR"
sudo chown -R "$KIOSK_USER:$KIOSK_USER" "$ANKI_SYNC_DIR"
if ! sudo -u "$KIOSK_USER" python3 -m venv "$ANKI_SYNC_DIR"; then
log_error "Failed to create virtual environment"
return 1
fi
# Debian/Ubuntu's python3-venv doesn't always bootstrap pip into a
# fresh venv on its own (ensurepip behavior varies by release) - fall
# back to ensurepip explicitly rather than failing the whole install
# over it.
if ! sudo -u "$KIOSK_USER" test -x "$ANKI_SYNC_DIR/bin/pip"; then
sudo -u "$KIOSK_USER" "$ANKI_SYNC_DIR/bin/python" -m ensurepip --upgrade
fi
echo "Installing the 'anki' package (this can take a minute)..."
if ! sudo -u "$KIOSK_USER" "$ANKI_SYNC_DIR/bin/pip" install --upgrade pip anki; then
log_error "pip install anki failed"
return 1
fi
echo
echo "Set up the first sync account (used by Anki clients to log in):"
local username password port
username=$(anki_sync_ask_username "kiosk")
password=$(anki_sync_ask_password "$username")
port=$(ask_integer "Port to listen on" "8080" 1024 65535)
sudo mkdir -p "$ANKI_SYNC_DIR/data"
sudo chown "$KIOSK_USER:$KIOSK_USER" "$ANKI_SYNC_DIR/data"
anki_sync_write_base_env "$port"
anki_sync_append_user_line 1 "$username" "$password"
anki_sync_write_unit_file
if enable_and_start_units kiosk-anki-sync; then
sudo ufw allow "${port}/tcp" comment 'Anki Sync Server' 2>/dev/null || true
log_success "Anki Sync Server installed: http://$(get_ip_address):${port}/"
echo " In Anki desktop: Preferences > Network > Self-hosted sync server"
echo " Log in with: $username"
else
log_error "Installed but failed to start - check: sudo journalctl -u kiosk-anki-sync -n 50"
return 1
fi
}
action_anki_sync_add_user() {
echo
local next_index
next_index=$(( $(anki_sync_user_count) + 1 ))
local username password
username=$(anki_sync_ask_username "user${next_index}")
password=$(anki_sync_ask_password "$username")
anki_sync_append_user_line "$next_index" "$username" "$password"
sudo systemctl restart kiosk-anki-sync
log_success "Added sync user: $username"
}
action_anki_sync_reconfigure_port() {
echo
local current_port
current_port=$(anki_sync_port)
local port
port=$(ask_integer "Port to listen on" "$current_port" 1024 65535)
if [[ "$port" == "$current_port" ]]; then
echo "No change"
return
fi
sudo sed -i "s/^SYNC_PORT=.*/SYNC_PORT=${port}/" "$(anki_sync_env_file)"
sudo systemctl restart kiosk-anki-sync
sudo ufw allow "${port}/tcp" comment 'Anki Sync Server' 2>/dev/null || true
log_success "Anki Sync Server now listening on port ${port}"
}
action_anki_sync_restart() {
sudo systemctl restart kiosk-anki-sync
sleep 1
if anki_sync_is_active; then
log_success "Anki Sync Server restarted"
else
log_error "Anki Sync Server failed to restart - check: sudo journalctl -u kiosk-anki-sync -n 50"
return 1
fi
}
action_anki_sync_uninstall() {
echo
ask_yes_no "Uninstall the Anki Sync Server? This deletes the synced collection data on this kiosk." "n" || { echo "Cancelled"; return; }
anki_sync_do_uninstall
}
# The actual removal, no prompt - shared with Complete Uninstall so that
# operation doesn't need to re-implement teardown a second time.
anki_sync_do_uninstall() {
sudo systemctl stop kiosk-anki-sync 2>/dev/null || true
sudo systemctl disable kiosk-anki-sync 2>/dev/null || true
sudo rm -f "$SYSTEMD_DIR/kiosk-anki-sync.service"
sudo systemctl daemon-reload 2>/dev/null || true
sudo rm -rf "$ANKI_SYNC_ENV_DIR" "$ANKI_SYNC_DIR"
log_success "Anki Sync Server removed"
}