#!/usr/bin/env bash # Gemeinsame Hilfsfunktionen fuer darktable-sync Scripts. # Dieses Script wird per `source` eingebunden, nicht direkt ausgefuehrt. CONFIG_DIR="$HOME/.config/darktable-sync" load_config() { local env_file="$CONFIG_DIR/.env" if [ ! -f "$env_file" ]; then echo "Fehler: Konfiguration nicht gefunden: $env_file" >&2 echo "Vorlage kopieren mit: cp .env.example $env_file" >&2 exit 1 fi # Berechtigungen pruefen: .env darf nicht world-readable sein local perms perms=$(stat -c '%a' "$env_file" 2>/dev/null || stat -f '%A' "$env_file" 2>/dev/null) if [[ "${perms: -1}" != "0" ]]; then echo "Warnung: $env_file ist world-readable. Empfehlung: chmod 600 $env_file" >&2 fi # Zeilen mit Shell-Operatoren abweisen (Kommentare und Leerzeilen ignorieren) if grep -vE '^\s*#|^\s*$' "$env_file" | grep -qE '[;|&`]'; then echo "Fehler: $env_file enthaelt unerlaubte Zeichen (; | & \`). Bitte pruefen." >&2 exit 1 fi # shellcheck source=/dev/null . "$env_file" } require_var() { local var_name="$1" if [ -z "${!var_name:-}" ]; then echo "Fehler: Variable '$var_name' ist nicht gesetzt in $CONFIG_DIR/.env" >&2 exit 1 fi } validate_path() { local var_name="$1" value="${!1:-}" # Pfade duerfen keine Shell-Sonderzeichen oder Path-Traversal enthalten if echo "$value" | grep -qE "['\";|&\`\$()\\\\]" || [[ "$value" == *".."* ]]; then echo "Fehler: '$var_name' enthaelt unerlaubte Zeichen: $value" >&2 exit 1 fi } validate_config() { require_var SERVER_IP require_var SERVER_USER require_var SERVER_SSH_PORT require_var SERVER_DB_DIR require_var SERVER_PHOTO_DIR require_var LOCAL_DARKTABLE_DB_DIR require_var LOCAL_PHOTO_DIR require_var SYNC_BIN require_var DARKTABLE_BIN validate_path SERVER_DB_DIR validate_path SERVER_PHOTO_DIR validate_path LOCAL_DARKTABLE_DB_DIR validate_path LOCAL_PHOTO_DIR # DARKTABLE_BIN: basename muss 'darktable' sein if [[ "$(basename "$DARKTABLE_BIN")" != "darktable" ]]; then echo "Fehler: DARKTABLE_BIN muss auf 'darktable' zeigen, nicht auf '$(basename "$DARKTABLE_BIN")'." >&2 exit 1 fi } check_dependency() { local cmd="$1" pkg="${2:-$1}" if ! command -v "$cmd" &>/dev/null; then echo "Fehler: '$cmd' ist nicht installiert." >&2 echo "Installieren mit: sudo apt install $pkg" >&2 exit 1 fi } log() { echo "$*" } log_step() { echo "" echo "=== $* ===" } log_error() { echo "FEHLER: $*" >&2 } # Prüft ob lokale und Server-Unison-Version übereinstimmen. # Unison erfordert exakt gleiche Version auf beiden Seiten. check_unison_versions() { local local_ver server_ver local_ver=$(unison -version 2>/dev/null | head -1 || true) server_ver=$(ssh_server "unison -version 2>/dev/null | head -1 || echo 'nicht installiert'" || true) log " Unison lokal: ${local_ver:-unbekannt}" log " Unison Server: ${server_ver:-unbekannt}" if [ "${local_ver:-}" != "${server_ver:-}" ]; then log_error "Unison-Version stimmt nicht überein: lokal='$local_ver', server='$server_ver'" log_error "Gleiche Unison-Version auf beiden Seiten installieren: sudo apt install unison" notify-send "Darktable Sync – Fehler" \ "Unison-Versionsmismatch\nLokal: $local_ver\nServer: $server_ver" -u critical touch "$CONFIG_DIR/sync_pending" exit 1 fi } # Zählt Änderungen aus Unison-Dryrun-Output nach Richtung und Typ. count_unison_changes() { local log_file="$1" direction="$2" type="$3" case "$direction:$type" in up:new) grep -cE 'new file.*---->' "$log_file" 2>/dev/null ;; up:changed) grep -cE 'changed.*---->' "$log_file" 2>/dev/null ;; up:deleted) grep -cE 'deleted.*---->' "$log_file" 2>/dev/null ;; down:new) grep -cE '<----.*new file' "$log_file" 2>/dev/null ;; down:changed) grep -cE '<----.*changed' "$log_file" 2>/dev/null ;; down:deleted) grep -cE '<----.*deleted' "$log_file" 2>/dev/null ;; *) echo 0; return ;; esac || echo 0 } # Liest die Anzahl übertragener Dateien aus dem Unison-Execute-Output. count_unison_transferred() { local log_file="$1" grep -oP '\d+(?= item\(s\) transferred)' "$log_file" 2>/dev/null | tail -1 || echo 0 } classify_filetype() { local file="$1" local ext="${file##*.}"; ext="${ext,,}" case "$ext" in jpg|jpeg|png|tif|tiff|dng|cr2|cr3|nef|arw|orf|rw2|raf|raw) echo "Foto" ;; xmp) echo "XMP" ;; db|bak) echo "Datenbank" ;; mp4|mov|avi|mkv|mts|m2ts) echo "Video" ;; *) echo "Sonstiges" ;; esac } format_unison_details() { local log_file="$1" direction_label="$2" direction="$3" [ -f "$log_file" ] || return 0 local neu_pat upd_pat del_pat if [ "$direction" = "up" ]; then neu_pat='new file.*---->' upd_pat='changed.*---->' del_pat='deleted.*---->' else neu_pat='<----.*new file' upd_pat='<----.*changed' del_pat='<----.*deleted' fi local label pattern files for entry in "neu:$neu_pat" "aktualisiert:$upd_pat" "gelöscht:$del_pat"; do label="${entry%%:*}" pattern="${entry#*:}" files=$(grep -E "$pattern" "$log_file" 2>/dev/null \ | awk '{print $NF}' | sort) || true [ -n "$files" ] || continue declare -A dir_foto=() dir_xmp=() dir_sonstige=() while IFS= read -r f; do local fdir ftyp fdir=$(dirname "$f") ftyp=$(classify_filetype "$f") case "$ftyp" in Foto) dir_foto["$fdir"]=$(( ${dir_foto["$fdir"]:-0} + 1 )) ;; XMP) dir_xmp["$fdir"]=$(( ${dir_xmp["$fdir"]:-0} + 1 )) ;; *) dir_sonstige["$fdir"]=$(( ${dir_sonstige["$fdir"]:-0} + 1 )) ;; esac done <<< "$files" local dirs dirs=$(printf '%s\n' "${!dir_foto[@]}" "${!dir_xmp[@]}" "${!dir_sonstige[@]}" \ | sort -u) if [ -n "$dirs" ]; then log_step "$direction_label ($label)" while IFS= read -r fdir; do local nf nx ns parts nf=${dir_foto["$fdir"]:-0} nx=${dir_xmp["$fdir"]:-0} ns=${dir_sonstige["$fdir"]:-0} parts="" if [ "$nf" -gt 0 ]; then parts="${nf} Fotos"; fi if [ "$nx" -gt 0 ]; then if [ -n "$parts" ]; then parts="$parts, "; fi parts="${parts}${nx} XMP" fi if [ "$ns" -gt 0 ]; then if [ -n "$parts" ]; then parts="$parts, "; fi parts="${parts}${ns} Sonstige" fi log " ${fdir}/ $parts" done <<< "$dirs" fi unset dir_foto dir_xmp dir_sonstige done } cleanup_old_backups() { local backup_dir="$1" [ -d "$backup_dir" ] || return 0 local count count=$(find "$backup_dir" -type f -mtime +730 | wc -l) if [ "$count" -gt 0 ]; then log "Backup-Bereinigung: $count Datei(en) älter als 2 Jahre in $backup_dir" find "$backup_dir" -type f -mtime +730 -delete find "$backup_dir" -type d -empty -delete 2>/dev/null || true fi } confirm_dry_run() { [ "${DRY_RUN_SKIP_CONFIRM:-0}" = "1" ] && return 0 ask_user "Darktable Sync – Trockenlauf" \ "Trockenlauf starten?\n\nEs werden keine Dateien verändert oder übertragen." } ssh_server() { ssh -o ConnectTimeout=5 -o BatchMode=yes \ -p "$SERVER_SSH_PORT" "$SERVER_USER@$SERVER_IP" "$@" } # Liefert den Unix-Timestamp (mtime) von library.db auf dem Server, oder "0" wenn nicht vorhanden. server_db_mtime() { ssh_server "stat -c '%Y' '$SERVER_DB_DIR/library.db' 2>/dev/null || echo 0" } save_sync_token() { echo "$1" > "$CONFIG_DIR/sync_token" } read_sync_token() { cat "$CONFIG_DIR/sync_token" 2>/dev/null || echo "" } server_reachable() { ssh_server true 2>/dev/null } ask_user() { local title="$1" text="$2" ans if [ "${DARKTABLE_SYNC_MODE:-}" = "gui" ] && command -v zenity &>/dev/null; then zenity --question --title="$title" --text="$text" 2>/dev/null return $? elif [ "${DARKTABLE_SYNC_MODE:-}" = "gui" ] && command -v kdialog &>/dev/null; then kdialog --title "$title" --yesno "$text" 2>/dev/null return $? else printf '%b\n' "$text" read -r -p "[j/N] " ans || true [[ "$ans" =~ ^[jJyY] ]] return $? fi } # Fragt den User wie mit einem Sync-Token-Konflikt umgegangen werden soll. # Gibt "download", "upload" oder "abort" aus. ask_conflict_resolution() { local TITLE="Darktable Sync – Konflikt" local EXPLAIN="Ein anderer Rechner hat die Datenbank seit deinem letzten Sync verändert.\nDeine lokalen Änderungen wurden noch NICHT auf den Server übertragen.\n\nWas soll passieren?" if [ "${DARKTABLE_SYNC_MODE:-}" = "gui" ] && command -v zenity &>/dev/null; then local choice choice=$(zenity --list \ --title="$TITLE" \ --text="$EXPLAIN" \ --radiolist \ --column="" --column="Aktion" --column="Beschreibung" \ TRUE "Herunterladen" "Server-Stand übernehmen (empfohlen)" \ FALSE "Hochladen erzwingen" "Lokale Version auf Server schreiben – Server-Änderungen gehen verloren!" \ FALSE "Abbrechen" "Nichts tun – Sync wird übersprungen" \ --width=520 --height=260 2>/dev/null) || true case "$choice" in "Hochladen erzwingen") echo "upload" ;; "Abbrechen") echo "abort" ;; *) echo "download" ;; esac elif [ "${DARKTABLE_SYNC_MODE:-}" = "gui" ] && command -v kdialog &>/dev/null; then local btn btn=$(kdialog --title "$TITLE" \ --menu "$EXPLAIN" \ download "Herunterladen (empfohlen)" \ upload "Hochladen erzwingen (Server-Änderungen gehen verloren!)" \ abort "Abbrechen" 2>/dev/null) || true case "$btn" in upload|abort) echo "$btn" ;; *) echo "download" ;; esac else echo "" echo "=== $TITLE ===" echo "Ein anderer Rechner hat die Datenbank seit deinem letzten Sync verändert." echo "Deine lokalen Änderungen wurden noch NICHT auf den Server übertragen." echo "" echo " 1) Herunterladen (empfohlen) – Server-Stand übernehmen" echo " 2) Hochladen erzwingen – lokale Version gewinnt, Server-Änderungen gehen verloren" echo " 3) Abbrechen" local ans read -r -p "Auswahl [1/2/3, Standard: 1]: " ans || true case "$ans" in 2) echo "upload" ;; 3) echo "abort" ;; *) echo "download" ;; esac fi }