Integrates Borg backups for emulators
• Removes legacy backup scripts • Adds new Borg wrapper with automated backup and rotation • Introduces a TUI for managing Borg repositories • Updates emulator launchers to use the new backup mechanism
This commit is contained in:
parent
f909328573
commit
6bee5e8d80
5 changed files with 376 additions and 361 deletions
|
@ -1,345 +0,0 @@
|
|||
{ pkgs, ... }:
|
||||
pkgs.writeScript "backup-wrapper" ''
|
||||
#!/usr/bin/env fish
|
||||
|
||||
#==========================================================#
|
||||
# Function definitions #
|
||||
#==========================================================#
|
||||
|
||||
# Set up colors for prettier output
|
||||
set -l blue (set_color blue)
|
||||
set -l green (set_color green)
|
||||
set -l yellow (set_color yellow)
|
||||
set -l red (set_color red)
|
||||
set -l cyan (set_color cyan)
|
||||
set -l magenta (set_color magenta)
|
||||
set -l bold (set_color --bold)
|
||||
set -l normal (set_color normal)
|
||||
|
||||
# Define log file path
|
||||
set -g log_file ""
|
||||
|
||||
function setup_logging
|
||||
set -g log_file "$argv[1]/backup.log"
|
||||
echo "# Backup Wrapper Log - Started at "(date) > $log_file
|
||||
echo "# =====================================================" >> $log_file
|
||||
end
|
||||
|
||||
# Use conditional tee: if log_file is set, tee output; otherwise echo normally.
|
||||
function print_header
|
||||
set -l header "$blue═══════════════[ $bold$argv[1]$normal$blue ]═══════════════$normal"
|
||||
if test -n "$log_file"
|
||||
echo $header | tee -a $log_file
|
||||
else
|
||||
echo $header
|
||||
end
|
||||
end
|
||||
|
||||
function print_step
|
||||
set -l msg "$green→ $bold$argv[1]$normal"
|
||||
if test -n "$log_file"
|
||||
echo $msg | tee -a $log_file
|
||||
else
|
||||
echo $msg
|
||||
end
|
||||
end
|
||||
|
||||
function print_info
|
||||
set -l msg "$cyan•$normal $argv[1]"
|
||||
if test -n "$log_file"
|
||||
echo $msg | tee -a $log_file
|
||||
else
|
||||
echo $msg
|
||||
end
|
||||
end
|
||||
|
||||
function print_warning
|
||||
set -l msg "$yellow⚠$normal $argv[1]"
|
||||
if test -n "$log_file"
|
||||
echo $msg | tee -a $log_file >&2
|
||||
else
|
||||
echo $msg >&2
|
||||
end
|
||||
end
|
||||
|
||||
function print_error
|
||||
set -l msg "$red✖$normal $argv[1]"
|
||||
if test -n "$log_file"
|
||||
echo $msg | tee -a $log_file >&2
|
||||
else
|
||||
echo $msg >&2
|
||||
end
|
||||
end
|
||||
|
||||
function print_success
|
||||
set -l msg "$green✓$normal $argv[1]"
|
||||
if test -n "$log_file"
|
||||
echo $msg | tee -a $log_file
|
||||
else
|
||||
echo $msg
|
||||
end
|
||||
end
|
||||
|
||||
function print_usage
|
||||
print_header "Backup Wrapper Usage"
|
||||
if test -n "$log_file"
|
||||
echo "Usage: backup_wrapper [OPTIONS] -- COMMAND [ARGS...]" | tee -a $log_file
|
||||
echo "Options:" | tee -a $log_file
|
||||
echo " -p, --path PATH Path to backup" | tee -a $log_file
|
||||
echo " -o, --output PATH Output directory for backups" | tee -a $log_file
|
||||
echo " -m, --max NUMBER Maximum number of backups to keep (default: 5)" | tee -a $log_file
|
||||
echo " -d, --delay SECONDS Delay before backup after changes (default: 5)" | tee -a $log_file
|
||||
echo " -h, --help Show this help message" | tee -a $log_file
|
||||
else
|
||||
echo "Usage: backup_wrapper [OPTIONS] -- COMMAND [ARGS...]"
|
||||
echo "Options:"
|
||||
echo " -p, --path PATH Path to backup"
|
||||
echo " -o, --output PATH Output directory for backups"
|
||||
echo " -m, --max NUMBER Maximum number of backups to keep (default: 5)"
|
||||
echo " -d, --delay SECONDS Delay before backup after changes (default: 5)"
|
||||
echo " -h, --help Show this help message"
|
||||
end
|
||||
end
|
||||
|
||||
# This is the critical function - needs to return *only* the backup file path
|
||||
function backup_path
|
||||
set -l src $argv[1]
|
||||
set -l out $argv[2]
|
||||
set -l timestamp (date +"%Y%m%d-%H%M%S")
|
||||
set -l backup_file "$out/backup-$timestamp.tar.zst"
|
||||
|
||||
# Log messages to stderr so they don't interfere with the function output
|
||||
echo "$green→$normal Backing up to $yellow$backup_file$normal" >&2 | tee -a $log_file
|
||||
pushd (dirname "$src") >/dev/null
|
||||
tar cf - (basename "$src") | ${pkgs.zstd}/bin/zstd -c -T5 -15 > "$backup_file" 2>> $log_file
|
||||
set -l exit_status $status
|
||||
popd >/dev/null
|
||||
|
||||
if test $exit_status -eq 0
|
||||
# IMPORTANT: Only output the backup file path, nothing else
|
||||
echo $backup_file
|
||||
else
|
||||
echo "$red✖$normal Backup operation failed!" >&2 | tee -a $log_file
|
||||
return 1
|
||||
end
|
||||
end
|
||||
|
||||
function rotate_backups
|
||||
set -l output_dir $argv[1]
|
||||
set -l max_backups $argv[2]
|
||||
|
||||
set -l backups (ls -t "$output_dir"/backup-*.tar.zst 2>/dev/null)
|
||||
set -l num_backups (count $backups)
|
||||
|
||||
if test $num_backups -gt $max_backups
|
||||
print_step "Rotating backups, keeping $max_backups of $num_backups"
|
||||
for i in (seq (math "$max_backups + 1") $num_backups)
|
||||
print_info "Removing old backup: $yellow$backups[$i]$normal"
|
||||
rm -f "$backups[$i]"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
#==========================================================#
|
||||
# Argument parsing #
|
||||
#==========================================================#
|
||||
|
||||
# Parse arguments
|
||||
set -l backup_path ""
|
||||
set -l output_dir ""
|
||||
set -l max_backups 5
|
||||
set -l delay 5
|
||||
set -l cmd ""
|
||||
|
||||
while count $argv > 0
|
||||
switch $argv[1]
|
||||
case -h --help
|
||||
print_usage
|
||||
exit 0
|
||||
case -p --path
|
||||
set -e argv[1]
|
||||
set backup_path $argv[1]
|
||||
set -e argv[1]
|
||||
case -o --output
|
||||
set -e argv[1]
|
||||
set output_dir $argv[1]
|
||||
set -e argv[1]
|
||||
case -m --max
|
||||
set -e argv[1]
|
||||
set max_backups $argv[1]
|
||||
set -e argv[1]
|
||||
case -d --delay
|
||||
set -e argv[1]
|
||||
set delay $argv[1]
|
||||
set -e argv[1]
|
||||
case --
|
||||
set -e argv[1]
|
||||
set cmd $argv
|
||||
break
|
||||
case '*'
|
||||
print_error "Unknown option $argv[1]"
|
||||
print_usage
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
#==========================================================#
|
||||
# Validation & Setup #
|
||||
#==========================================================#
|
||||
|
||||
# Ensure the output directory exists
|
||||
mkdir -p "$output_dir" 2>/dev/null
|
||||
|
||||
# Set up logging
|
||||
setup_logging "$output_dir"
|
||||
|
||||
print_header "Backup Wrapper Starting"
|
||||
|
||||
# Log the original command
|
||||
echo "# Original command: $argv" >> $log_file
|
||||
|
||||
# Validate arguments
|
||||
if test -z "$backup_path" -o -z "$output_dir" -o -z "$cmd"
|
||||
print_error "Missing required arguments"
|
||||
print_usage
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Display configuration
|
||||
print_info "Backup path: $yellow$backup_path$normal"
|
||||
print_info "Output path: $yellow$output_dir$normal"
|
||||
print_info "Max backups: $yellow$max_backups$normal"
|
||||
print_info "Backup delay: $yellow$delay seconds$normal"
|
||||
print_info "Command: $yellow$cmd$normal"
|
||||
print_info "Log file: $yellow$log_file$normal"
|
||||
|
||||
# Validate the backup path exists
|
||||
if not test -e "$backup_path"
|
||||
print_error "Backup path '$backup_path' does not exist"
|
||||
exit 1
|
||||
end
|
||||
|
||||
|
||||
#==========================================================#
|
||||
# Initial backup #
|
||||
#==========================================================#
|
||||
|
||||
print_header "Creating Initial Backup"
|
||||
|
||||
# Using command substitution to capture only the path output
|
||||
set -l initial_backup (backup_path "$backup_path" "$output_dir")
|
||||
set -l status_code $status
|
||||
|
||||
if test $status_code -ne 0
|
||||
print_error "Initial backup failed"
|
||||
exit 1
|
||||
end
|
||||
print_success "Initial backup created: $yellow$initial_backup$normal"
|
||||
|
||||
#==========================================================#
|
||||
# Start wrapped process #
|
||||
#==========================================================#
|
||||
|
||||
print_header "Starting Wrapped Process"
|
||||
|
||||
# Start the wrapped process in the background
|
||||
print_step "Starting wrapped process: $yellow$cmd$normal"
|
||||
|
||||
# Using exactly the same execution method as the original working script
|
||||
$cmd >> $log_file 2>&1 &
|
||||
set -l pid $last_pid
|
||||
print_success "Process started with PID: $yellow$pid$normal"
|
||||
|
||||
# Set up cleanup function
|
||||
function cleanup --on-signal INT --on-signal TERM
|
||||
print_warning "Caught signal, cleaning up..."
|
||||
kill $pid 2>/dev/null
|
||||
wait $pid 2>/dev/null
|
||||
echo "# Script terminated by signal at "(date) >> $log_file
|
||||
exit 0
|
||||
end
|
||||
|
||||
#==========================================================#
|
||||
# Monitoring loop #
|
||||
#==========================================================#
|
||||
|
||||
print_header "Monitoring for Changes"
|
||||
|
||||
# Monitor for changes and create backups
|
||||
set -l change_detected 0
|
||||
set -l last_backup_time (date +%s)
|
||||
|
||||
print_step "Monitoring $yellow$backup_path$normal for changes..."
|
||||
|
||||
while true
|
||||
# Check if the process is still running
|
||||
if not kill -0 $pid 2>/dev/null
|
||||
print_warning "Wrapped process exited, stopping monitor"
|
||||
break
|
||||
end
|
||||
|
||||
# Using inotifywait to detect changes
|
||||
${pkgs.inotify-tools}/bin/inotifywait -r -q -e modify,create,delete,move "$backup_path" -t 1
|
||||
set -l inotify_status $status
|
||||
|
||||
if test $inotify_status -eq 0
|
||||
# Change detected
|
||||
set change_detected 1
|
||||
set -l current_time (date +%s)
|
||||
set -l time_since_last (math "$current_time - $last_backup_time")
|
||||
|
||||
if test $time_since_last -ge $delay
|
||||
print_step "Changes detected, creating backup"
|
||||
set -l new_backup (backup_path "$backup_path" "$output_dir")
|
||||
set -l backup_status $status
|
||||
|
||||
if test $backup_status -eq 0
|
||||
print_success "Backup created: $yellow$new_backup$normal"
|
||||
rotate_backups "$output_dir" "$max_backups"
|
||||
set last_backup_time (date +%s)
|
||||
set change_detected 0
|
||||
else
|
||||
print_error "Backup failed"
|
||||
end
|
||||
else
|
||||
print_info "Change detected, batching with other changes ($yellow$delay$normal seconds delay)"
|
||||
end
|
||||
else if test $change_detected -eq 1
|
||||
# No new changes but we had some changes before
|
||||
set -l current_time (date +%s)
|
||||
set -l time_since_last (math "$current_time - $last_backup_time")
|
||||
|
||||
if test $time_since_last -ge $delay
|
||||
print_step "Creating backup after batching changes"
|
||||
set -l new_backup (backup_path "$backup_path" "$output_dir")
|
||||
set -l backup_status $status
|
||||
|
||||
if test $backup_status -eq 0
|
||||
print_success "Backup created: $yellow$new_backup$normal"
|
||||
rotate_backups "$output_dir" "$max_backups"
|
||||
set last_backup_time (date +%s)
|
||||
set change_detected 0
|
||||
else
|
||||
print_error "Backup failed"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
#==========================================================#
|
||||
# Cleanup & Exit #
|
||||
#==========================================================#
|
||||
|
||||
print_header "Finishing Up"
|
||||
|
||||
# Wait for the wrapped process to finish
|
||||
print_step "Waiting for process to finish..."
|
||||
wait $pid
|
||||
set -l exit_code $status
|
||||
print_success "Process finished with exit code: $yellow$exit_code$normal"
|
||||
|
||||
# Add final log entry
|
||||
echo "# Script completed at "(date)" with exit code $exit_code" >> $log_file
|
||||
|
||||
exit $exit_code
|
||||
''
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
# :D
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
# This module just provides a customized .desktop file with gamescope args dynamically created based on the
|
||||
# host's monitors configuration
|
||||
# switch.nix
|
||||
{
|
||||
pkgs,
|
||||
config,
|
||||
|
@ -8,26 +7,156 @@
|
|||
}:
|
||||
|
||||
let
|
||||
|
||||
path = lib.custom.relativeToRoot "pkgs/common/citron-emu/package.nix";
|
||||
citron-emu = pkgs.callPackage path { inherit pkgs; };
|
||||
|
||||
backup-wrapper = import ./scripts/backup.nix { inherit pkgs; };
|
||||
citron-emu = pkgs.callPackage (lib.custom.relativeToRoot "pkgs/common/citron-emu/package.nix") {
|
||||
inherit pkgs;
|
||||
};
|
||||
borgtui = pkgs.callPackage (lib.custom.relativeToRoot "pkgs/common/borgtui/package.nix") {
|
||||
inherit pkgs;
|
||||
};
|
||||
|
||||
user = config.hostSpec.username;
|
||||
|
||||
borg-wrapper = pkgs.writeScript "borg-wrapper" ''
|
||||
#!${lib.getExe pkgs.fish}
|
||||
|
||||
# Enable strict error handling
|
||||
set -e
|
||||
|
||||
# Parse arguments
|
||||
set -l CMD
|
||||
|
||||
while test (count $argv) -gt 0
|
||||
switch $argv[1]
|
||||
case -p --path
|
||||
set BACKUP_PATH $argv[2]
|
||||
set -e argv[1..2]
|
||||
case -o --output
|
||||
set BORG_REPO $argv[2]
|
||||
set -e argv[1..2]
|
||||
case -m --max
|
||||
set MAX_BACKUPS $argv[2]
|
||||
set -e argv[1..2]
|
||||
case --
|
||||
set -e argv[1]
|
||||
set CMD $argv
|
||||
set -e argv[1..-1]
|
||||
break
|
||||
case '*'
|
||||
echo "Unknown option: $argv[1]"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
# Initialize Borg repository
|
||||
mkdir -p "$BORG_REPO"
|
||||
if not ${pkgs.borgbackup}/bin/borg list "$BORG_REPO" &>/dev/null
|
||||
echo "Initializing new Borg repository at $BORG_REPO"
|
||||
${pkgs.borgbackup}/bin/borg init --encryption=none "$BORG_REPO"
|
||||
end
|
||||
|
||||
# Backup functions with error suppression
|
||||
function create_backup
|
||||
set -l tag $argv[1]
|
||||
set -l timestamp (date +%Y%m%d-%H%M%S)
|
||||
echo "Creating $tag backup: $timestamp"
|
||||
${pkgs.borgbackup}/bin/borg create --stats --compression zstd,15 \
|
||||
--files-cache=mtime,size \
|
||||
--lock-wait 5 \
|
||||
"$BORG_REPO::$tag-$timestamp" "$BACKUP_PATH" || true
|
||||
end
|
||||
|
||||
function prune_backups
|
||||
echo "Pruning old backups"
|
||||
${pkgs.borgbackup}/bin/borg prune --keep-last "$MAX_BACKUPS" --stats "$BORG_REPO" || true
|
||||
end
|
||||
|
||||
# Initial backup
|
||||
create_backup "initial"
|
||||
prune_backups
|
||||
|
||||
# Start emulator in a subprocess group
|
||||
fish -c "
|
||||
function on_exit
|
||||
exit 0
|
||||
end
|
||||
|
||||
trap on_exit INT TERM
|
||||
exec $CMD
|
||||
" &
|
||||
set PID (jobs -lp | tail -n1)
|
||||
|
||||
# Cleanup function
|
||||
function cleanup
|
||||
# Send TERM to process group
|
||||
kill -TERM -$PID 2>/dev/null || true
|
||||
wait $PID 2>/dev/null || true
|
||||
create_backup "final"
|
||||
prune_backups
|
||||
end
|
||||
|
||||
function on_exit --on-signal INT --on-signal TERM
|
||||
cleanup
|
||||
end
|
||||
|
||||
# Debounced backup trigger
|
||||
set last_backup (date +%s)
|
||||
set backup_cooldown 30 # Minimum seconds between backups
|
||||
|
||||
# Watch loop with timeout
|
||||
while kill -0 $PID 2>/dev/null
|
||||
# Wait for changes with 5-second timeout
|
||||
if ${pkgs.inotify-tools}/bin/inotifywait \
|
||||
-r \
|
||||
-qq \
|
||||
-e close_write,delete,moved_to \
|
||||
-t 5 \
|
||||
"$BACKUP_PATH"
|
||||
|
||||
set current_time (date +%s)
|
||||
if test (math "$current_time - $last_backup") -ge $backup_cooldown
|
||||
create_backup "auto"
|
||||
prune_backups
|
||||
set last_backup $current_time
|
||||
else
|
||||
echo "Skipping backup:" + (math "$backup_cooldown - ($current_time - $last_backup)") + "s cooldown remaining"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
cleanup
|
||||
exit 0
|
||||
'';
|
||||
|
||||
# Generic function to create launcher scripts
|
||||
mkLaunchCommand =
|
||||
{
|
||||
savePath, # Path to the save directory
|
||||
backupPath, # Path where backups should be stored
|
||||
maxBackups ? 30, # Maximum number of backups to keep
|
||||
command, # Command to execute
|
||||
}:
|
||||
"${borg-wrapper} -p \"${savePath}\" -o \"${backupPath}\" -m ${toString maxBackups} -- ${command}";
|
||||
|
||||
in
|
||||
{
|
||||
home.packages = with pkgs; [
|
||||
citron-emu
|
||||
ryubing
|
||||
borgbackup
|
||||
borgtui
|
||||
inotify-tools
|
||||
];
|
||||
|
||||
xdg.desktopEntries = {
|
||||
Ryujinx = {
|
||||
name = "Ryubing w/ Backups";
|
||||
comment = "Ryubing Emulator with Save Backups";
|
||||
exec = ''fish ${backup-wrapper} -p /home/${user}/.config/Ryujinx/bis/user/save -o /pool/Backups/Switch/RyubingSaves -m 30 -d 120 -- ryujinx'';
|
||||
name = "Ryujinx w/ Borg Backups";
|
||||
comment = "Ryujinx Emulator with Borg Backups";
|
||||
exec = mkLaunchCommand {
|
||||
savePath = "/home/${user}/.config/Ryujinx/bis/user/save";
|
||||
backupPath = "/pool/Backups/Switch/RyubingSaves";
|
||||
maxBackups = 30;
|
||||
command = "ryujinx";
|
||||
};
|
||||
icon = "Ryujinx";
|
||||
type = "Application";
|
||||
terminal = false;
|
||||
|
@ -50,9 +179,14 @@ in
|
|||
};
|
||||
|
||||
citron-emu = {
|
||||
name = "Citron w/ Backups";
|
||||
comment = "Citron Emulator with Save Backups";
|
||||
exec = ''fish ${backup-wrapper} -p /home/${user}/.local/share/citron/nand/user/save -o /pool/Backups/Switch/CitronSaves -m 30 -d 120 -- citron-emu'';
|
||||
name = "Citron w/ Borg Backups";
|
||||
comment = "Citron Emulator with Borg Backups";
|
||||
exec = mkLaunchCommand {
|
||||
savePath = "/home/${user}/.local/share/citron/nand/user/save";
|
||||
backupPath = "/pool/Backups/Switch/CitronSaves";
|
||||
maxBackups = 30;
|
||||
command = "citron-emu";
|
||||
};
|
||||
icon = "applications-games";
|
||||
type = "Application";
|
||||
terminal = false;
|
||||
|
|
42
pkgs/common/borgtui/package.nix
Normal file
42
pkgs/common/borgtui/package.nix
Normal file
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
pkgs,
|
||||
lib,
|
||||
}:
|
||||
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "borgtui";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.; # This expects tui.sh to be in the same directory as this file
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
python3
|
||||
];
|
||||
|
||||
dontUnpack = true;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/bin
|
||||
cp ${./tui.sh} $out/bin/borgtui
|
||||
chmod +x $out/bin/borgtui
|
||||
|
||||
wrapProgram $out/bin/borgtui \
|
||||
--prefix PATH : ${lib.makeBinPath [ pkgs.borgbackup ]}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "A simple TUI for managing Borg repositories";
|
||||
# homepage = "https://github.com/toph/borgtui"; # Replace with your actual GitHub repo if you have one
|
||||
license = licenses.mit;
|
||||
platforms = platforms.all;
|
||||
maintainers = with maintainers; [ "tophc7" ]; # Add your name if you're a Nixpkgs maintainer
|
||||
};
|
||||
}
|
187
pkgs/common/borgtui/tui.sh
Normal file
187
pkgs/common/borgtui/tui.sh
Normal file
|
@ -0,0 +1,187 @@
|
|||
#!/usr/bin/env python3
|
||||
import curses
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
|
||||
def run_cmd(cmd):
|
||||
"""Run cmd list, return (success:bool, output:str)."""
|
||||
try:
|
||||
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
|
||||
return True, out.decode()
|
||||
except subprocess.CalledProcessError as e:
|
||||
return False, e.output.decode()
|
||||
|
||||
def get_archives(repo):
|
||||
"""List archives by running `borg list repo`."""
|
||||
success, out = run_cmd(["borg", "list", repo])
|
||||
if not success:
|
||||
return [], out
|
||||
archives = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split(maxsplit=1) # Split into name and rest
|
||||
if parts:
|
||||
archives.append(parts[0])
|
||||
return archives, ""
|
||||
|
||||
def prompt(stdscr, msg):
|
||||
curses.echo()
|
||||
h, w = stdscr.getmaxyx()
|
||||
stdscr.addstr(h-1, 0, msg)
|
||||
stdscr.clrtoeol()
|
||||
stdscr.refresh()
|
||||
inp = stdscr.getstr(h-1, len(msg)).decode()
|
||||
curses.noecho()
|
||||
return inp
|
||||
|
||||
def draw(stdscr, repo, archives, sel, offset, status):
|
||||
stdscr.erase()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Header
|
||||
header = f"Borg TUI - Repo: {repo}"
|
||||
stdscr.addstr(0, 0, header[:w-1])
|
||||
|
||||
# If no archives, show message
|
||||
if not archives:
|
||||
stdscr.addstr(2, 0, "No backups found in repository")
|
||||
stdscr.addstr(3, 0, "(q) to quit, (r) to refresh")
|
||||
else:
|
||||
# Menu
|
||||
visible = h - 4
|
||||
for idx, arch in enumerate(archives[offset:offset+visible]):
|
||||
y = idx + 1
|
||||
line = f"{idx+offset+1:2}. {arch}"[:w-1]
|
||||
if idx+offset == sel:
|
||||
stdscr.attron(curses.A_REVERSE)
|
||||
stdscr.addstr(y, 0, line)
|
||||
stdscr.attroff(curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(y, 0, line)
|
||||
|
||||
# Help line
|
||||
help_line = "↑↓:move d:delete r:restore q:quit"
|
||||
stdscr.addstr(h-3, 0, help_line[:w-1])
|
||||
|
||||
# Status line
|
||||
stdscr.addstr(h-2, 0, status[:w-1])
|
||||
stdscr.refresh()
|
||||
|
||||
def main(stdscr):
|
||||
# Check if arguments are provided
|
||||
if len(sys.argv) != 2:
|
||||
stdscr.erase()
|
||||
stdscr.addstr(0, 0, "Error: Repository path not provided")
|
||||
stdscr.addstr(1, 0, "Usage: ./tui.sh /path/to/borg/repository")
|
||||
stdscr.addstr(3, 0, "Press any key to exit...")
|
||||
stdscr.refresh()
|
||||
stdscr.getch()
|
||||
return
|
||||
|
||||
repo = sys.argv[1]
|
||||
curses.curs_set(0) # Hide cursor
|
||||
|
||||
# Check if repo exists
|
||||
if not os.path.isdir(repo):
|
||||
stdscr.addstr(0, 0, f"Error: Repository not found: {repo}")
|
||||
stdscr.addstr(1, 0, "Press any key to exit...")
|
||||
stdscr.getch()
|
||||
return
|
||||
|
||||
# Get archive list
|
||||
archives, err = get_archives(repo)
|
||||
if err:
|
||||
stdscr.addstr(0, 0, f"Error listing archives: {err}")
|
||||
stdscr.addstr(1, 0, "Press any key to exit...")
|
||||
stdscr.getch()
|
||||
return
|
||||
|
||||
sel = 0
|
||||
offset = 0
|
||||
status = "Ready"
|
||||
|
||||
# Main loop
|
||||
while True:
|
||||
h, w = stdscr.getmaxyx()
|
||||
max_disp = h - 4
|
||||
|
||||
# Handle scrolling
|
||||
if sel < offset:
|
||||
offset = sel
|
||||
elif sel >= offset + max_disp:
|
||||
offset = sel - max_disp + 1
|
||||
|
||||
draw(stdscr, repo, archives, sel, offset, status)
|
||||
try:
|
||||
key = stdscr.getch()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
# Navigation and actions
|
||||
if key in (curses.KEY_UP, ord('k')):
|
||||
if archives:
|
||||
sel = max(0, sel-1)
|
||||
status = "Moved up"
|
||||
elif key in (curses.KEY_DOWN, ord('j')):
|
||||
if archives:
|
||||
sel = min(len(archives)-1, sel+1)
|
||||
status = "Moved down"
|
||||
elif key == ord('q'):
|
||||
break
|
||||
elif key == ord('r'):
|
||||
# If 'r' is pressed with no archives, refresh list
|
||||
if not archives:
|
||||
archives, err = get_archives(repo)
|
||||
status = "Refreshed archive list"
|
||||
if err:
|
||||
status = f"Error: {err}"
|
||||
elif archives: # If archives exist, restore selected archive
|
||||
name = archives[sel]
|
||||
dest = prompt(stdscr, f"Restore {name} to dir: ")
|
||||
if dest:
|
||||
# Create destination directory
|
||||
try:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
status = f"Extracting {name} to {dest}..."
|
||||
draw(stdscr, repo, archives, sel, offset, status)
|
||||
|
||||
# Change to destination directory and extract
|
||||
cwd = os.getcwd()
|
||||
os.chdir(dest)
|
||||
ok, out = run_cmd(["borg", "extract", f"{repo}::{name}"])
|
||||
os.chdir(cwd) # Return to original directory
|
||||
|
||||
status = f"Restored {name} to {dest}" if ok else f"Error: {out.strip()}"
|
||||
except Exception as e:
|
||||
status = f"Error: {str(e)}"
|
||||
else:
|
||||
status = "Restore cancelled - No destination provided"
|
||||
|
||||
elif key == ord('d') and archives:
|
||||
name = archives[sel]
|
||||
ans = prompt(stdscr, f"Delete {name}? (y/N): ")
|
||||
if ans.lower().startswith('y'):
|
||||
# Delete with force flag
|
||||
ok, out = run_cmd(["borg", "delete", "--force", f"{repo}::{name}"])
|
||||
if ok:
|
||||
status = f"Deleted {name}"
|
||||
# Refresh archives list after deletion
|
||||
archives, err = get_archives(repo)
|
||||
sel = min(sel, max(0, len(archives)-1))
|
||||
else:
|
||||
status = f"Error: {out.strip()}"
|
||||
else:
|
||||
status = "Delete cancelled"
|
||||
|
||||
# Entry point with improved error handling
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
except KeyboardInterrupt:
|
||||
print("Exited Borg TUI")
|
||||
except Exception as e:
|
||||
# Log any uncaught exceptions
|
||||
with open("/tmp/borg-tui-error.log", "a") as f:
|
||||
f.write(f"{traceback.format_exc()}\n")
|
||||
print(f"Error: {str(e)}. See /tmp/borg-tui-error.log for details.")
|
Loading…
Add table
Reference in a new issue