#!/bin/zsh # # Gnommo Backup Utility # Syncs project files to an external drive using rsync # # Usage: ./backup.sh [options] # # Modes: # small - Keynotes, images, metadata, code (excludes large media) # big - Large video/audio files only (for offloading) # all - Complete mirror of entire project # # Drives: # lacie - /Volumes/LaCie Jens/gnommo # gnommodisk - /Volumes/gnommodisk/gnommo # status - Show backup status for all drives # # Options: # --dry-run Show what would be transferred without copying # --delete Delete files on destination that don't exist in source # --progress Show detailed transfer progress (default: on) # set -e # Configuration PROJECT_DIR="/Users/jenstandstad/Projects/gnommo" BACKUP_JSON="$PROJECT_DIR/backup.json" BIG_FILE_SIZE="100M" # Known drives (name -> mount path) typeset -A KNOWN_DRIVES KNOWN_DRIVES=( lacie "/Volumes/LaCie Jens" gnommodisk "/Volumes/gnommodisk" ) # Big file extensions (video/audio that tend to be large) BIG_EXTENSIONS=("mov" "mp4" "m4v" "avi" "mkv" "aifc" "aiff" "wav") # Initialize backup.json if it doesn't exist init_backup_json() { if [[ ! -f "$BACKUP_JSON" ]]; then cat > "$BACKUP_JSON" << 'EOF' { "drives": {} } EOF fi } # Update backup.json using Python (reliable JSON handling) update_backup_json() { local drive_name="$1" local backup_mode="$2" local backup_status="$3" # "started" or "completed" local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") python3 << PYTHON import json import os backup_file = "$BACKUP_JSON" drive_name = "$drive_name" mode = "$backup_mode" status = "$backup_status" timestamp = "$timestamp" # Load existing data if os.path.exists(backup_file): with open(backup_file, 'r') as f: data = json.load(f) else: data = {"drives": {}} # Ensure drive entry exists if drive_name not in data["drives"]: data["drives"][drive_name] = { "mount_path": "${KNOWN_DRIVES[$drive_name]:-$DESTINATION}", "backups": {} } # Ensure mode entry exists if mode not in data["drives"][drive_name]["backups"]: data["drives"][drive_name]["backups"][mode] = {} # Update based on status backup_entry = data["drives"][drive_name]["backups"][mode] if status == "started": backup_entry["last_attempt"] = timestamp backup_entry["last_status"] = "in_progress" elif status == "completed": backup_entry["last_completed"] = timestamp backup_entry["last_status"] = "success" elif status == "failed": backup_entry["last_status"] = "failed" # Write back with open(backup_file, 'w') as f: json.dump(data, f, indent=2) PYTHON } # Show backup status show_status() { echo "========================================" echo "Gnommo Backup Status" echo "========================================" if [[ ! -f "$BACKUP_JSON" ]]; then echo "No backups recorded yet." exit 0 fi python3 << 'PYTHON' import json import os from datetime import datetime backup_file = os.environ.get('BACKUP_JSON', 'backup.json') known_drives = {"lacie": "/Volumes/LaCie Jens", "gnommodisk": "/Volumes/gnommodisk"} with open(backup_file, 'r') as f: data = json.load(f) for drive_name, drive_info in data.get("drives", {}).items(): mount_path = drive_info.get("mount_path", "unknown") mounted = "CONNECTED" if os.path.exists(mount_path) else "not connected" print(f"\n{drive_name} ({mounted})") print(f" Path: {mount_path}") backups = drive_info.get("backups", {}) if not backups: print(" No backups recorded") continue for mode, info in backups.items(): status = info.get("last_status", "unknown") completed = info.get("last_completed", "never") attempt = info.get("last_attempt", "never") # Format the completed time nicely if completed != "never": try: dt = datetime.fromisoformat(completed.replace('Z', '+00:00')) completed = dt.strftime("%Y-%m-%d %H:%M UTC") except: pass status_icon = "✓" if status == "success" else "⋯" if status == "in_progress" else "✗" print(f" {mode}: {status_icon} {completed}") print() PYTHON echo "========================================" } usage() { cat << EOF Gnommo Backup Utility Usage: $(basename "$0") [options] Modes: small Sync small files only: Keynotes, images, JSON, code, manuscripts Excludes: .mov, .mp4, .aifc, and other large media files big Sync large files only: video and audio media files Useful for offloading to free up local space all Full mirror of the entire gnommo project status Show backup status for all known drives Drives: lacie /Volumes/LaCie Jens/gnommo gnommodisk /Volumes/gnommodisk/gnommo Or specify a custom path Options: --dry-run Preview what would be transferred (no actual copying) --delete Remove files on destination that no longer exist in source --no-progress Disable progress display --help Show this help message Examples: $(basename "$0") status $(basename "$0") small lacie $(basename "$0") big gnommodisk --delete $(basename "$0") all lacie --dry-run EOF exit 0 } # Parse arguments MODE="" DRIVE="" DESTINATION="" DRY_RUN="" DELETE="" PROGRESS="--progress" while [[ $# -gt 0 ]]; do case "$1" in small|big|all) MODE="$1" shift ;; status) export BACKUP_JSON show_status exit 0 ;; lacie|gnommodisk) DRIVE="$1" DESTINATION="${KNOWN_DRIVES[$1]}/gnommo" shift ;; --dry-run) DRY_RUN="--dry-run" shift ;; --delete) DELETE="--delete" shift ;; --no-progress) PROGRESS="" shift ;; --help|-h) usage ;; -*) echo "Unknown option: $1" usage ;; *) if [[ -z "$DESTINATION" ]]; then DRIVE="custom" DESTINATION="$1" fi shift ;; esac done # Handle status command if [[ "$MODE" == "status" ]]; then show_status exit 0 fi # Validate arguments if [[ -z "$MODE" ]]; then echo "Error: Mode is required (small, big, all, or status)" echo "" usage fi if [[ -z "$DESTINATION" ]]; then echo "Error: Drive or destination path is required" echo "" usage fi # Check if drive is mounted (get the volume path, handling spaces) MOUNT_PATH="${DESTINATION%/gnommo}" if [[ ! -d "$MOUNT_PATH" ]]; then echo "Error: Drive not mounted at: $MOUNT_PATH" echo "" echo "Available volumes:" ls /Volumes/ 2>/dev/null | sed 's/^/ /' exit 1 fi # Create destination directory if needed mkdir -p "$DESTINATION" # Initialize backup tracking init_backup_json # Build rsync command RSYNC_OPTS="-avh" [[ -n "$PROGRESS" ]] && RSYNC_OPTS="$RSYNC_OPTS --progress" [[ -n "$DRY_RUN" ]] && RSYNC_OPTS="$RSYNC_OPTS --dry-run" [[ -n "$DELETE" ]] && RSYNC_OPTS="$RSYNC_OPTS --delete" # Always exclude these EXCLUDE_ALWAYS=( ".DS_Store" "__pycache__" "*.pyc" ".git" ".env" "*.egg-info" ".venv" "venv" "node_modules" ) # Build exclusion patterns for big files build_big_excludes() { local excludes="" for ext in "${BIG_EXTENSIONS[@]}"; do excludes="$excludes --exclude='*.$ext'" done echo "$excludes" } # Build inclusion patterns for big files only build_big_includes() { local includes="" for ext in "${BIG_EXTENSIONS[@]}"; do includes="$includes --include='*.$ext'" done echo "$includes" } # Build common exclusions build_common_excludes() { local excludes="" for pattern in "${EXCLUDE_ALWAYS[@]}"; do excludes="$excludes --exclude='$pattern'" done echo "$excludes" } echo "========================================" echo "Gnommo Backup Utility" echo "========================================" echo "Mode: $MODE" echo "Drive: $DRIVE" echo "Source: $PROJECT_DIR" echo "Destination: $DESTINATION" [[ -n "$DRY_RUN" ]] && echo "DRY RUN: Yes (no files will be copied)" [[ -n "$DELETE" ]] && echo "Delete: Yes (will remove orphaned files)" echo "========================================" echo "" # Record backup attempt (skip for dry-run) if [[ -z "$DRY_RUN" ]]; then update_backup_json "$DRIVE" "$MODE" "started" fi # Track success BACKUP_SUCCESS=false run_backup() { case "$MODE" in small) echo "Syncing SMALL files (excluding large media)..." echo "Excludes: ${BIG_EXTENSIONS[*]}" echo "" EXCLUDES=$(build_common_excludes) BIG_EXCLUDES=$(build_big_excludes) eval rsync $RSYNC_OPTS $EXCLUDES $BIG_EXCLUDES "'$PROJECT_DIR/'" "'$DESTINATION/'" ;; big) echo "Syncing BIG files only (large media)..." echo "Includes: ${BIG_EXTENSIONS[*]}" echo "" EXCLUDES=$(build_common_excludes) INCLUDES="--include='*/' $(build_big_includes)" eval rsync $RSYNC_OPTS $EXCLUDES $INCLUDES --exclude="'*'" "'$PROJECT_DIR/'" "'$DESTINATION/'" ;; all) echo "Syncing ALL files (complete mirror)..." echo "" EXCLUDES=$(build_common_excludes) eval rsync $RSYNC_OPTS $EXCLUDES "'$PROJECT_DIR/'" "'$DESTINATION/'" ;; esac } # Run backup and track result # Exit codes: 0=success, 23=partial transfer (files changed during sync, usually OK), 24=vanished files run_backup && BACKUP_SUCCESS=true || { local exit_code=$? if [[ $exit_code -eq 23 || $exit_code -eq 24 ]]; then echo "Note: Some files changed during transfer (rsync exit $exit_code) - backup completed" BACKUP_SUCCESS=true fi } echo "" echo "========================================" if [[ -n "$DRY_RUN" ]]; then echo "DRY RUN complete. No files were copied." else if [[ "$BACKUP_SUCCESS" == true ]]; then update_backup_json "$DRIVE" "$MODE" "completed" echo "Backup complete!" else update_backup_json "$DRIVE" "$MODE" "failed" echo "Backup FAILED!" fi fi echo "========================================"