Adding handoff functionality for reviews
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
#!/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 "========================================"
|
||||
Reference in New Issue
Block a user