Files
gnommo/backup.sh
T

411 lines
10 KiB
Bash
Executable File

#!/bin/zsh
#
# Gnommo Backup Utility
# Syncs project files to an external drive using rsync
#
# Usage: ./backup.sh <mode> <drive> [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") <mode> <drive> [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
<path> 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 "========================================"