feat: Implement compressed backups and restore
Implements a new feature for creating compressed full backups and restoring from them. - Backups can now be created as compressed .tar.gz archives. - This option is only available for full backups to maintain the efficiency of incremental backups. - The UI now forces a full backup when compression is selected. - The backup list correctly identifies and labels compressed backups. - The restore process can handle both compressed and uncompressed backups. fix: Improve backup process feedback and reliability - Fixes a critical bug where backups could be overwritten if created on the same day. Backup names now include a timestamp to ensure uniqueness. - Improves UI feedback during compressed backups by showing distinct stages (transfer, compress) and using an indeterminate progress bar during the compression phase. - Disables the cancel button during the non-cancellable compression stage. - Fixes a bug where the incremental backup size was written to the info file for full backups. The correct total size is now used.
This commit is contained in:
@@ -133,10 +133,10 @@ class BackupManager:
|
||||
else:
|
||||
self.logger.log("No active backup process to cancel.")
|
||||
|
||||
def start_backup(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool = False, exclude_files: Optional[List[Path]] = None, source_size: int = 0):
|
||||
def start_backup(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool = False, exclude_files: Optional[List[Path]] = None, source_size: int = 0, is_compressed: bool = False):
|
||||
"""Starts a generic backup process for a specific path, reporting to a queue."""
|
||||
thread = threading.Thread(target=self._run_backup_path, args=(
|
||||
queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size))
|
||||
queue, source_path, dest_path, is_system, is_dry_run, exclude_files, source_size, is_compressed))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
@@ -160,7 +160,59 @@ class BackupManager:
|
||||
self.logger.log(f"Latest backup entry '{latest_backup_name}' was not a directory. No link will be used.")
|
||||
return None
|
||||
|
||||
def _run_backup_path(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool, exclude_files: Optional[List[Path]], source_size: int):
|
||||
def _compress_and_cleanup(self, dest_path: str, is_system: bool) -> bool:
|
||||
"""Compresses the backup directory and cleans up the original."""
|
||||
self.logger.log(f"Starting compression for: {dest_path}")
|
||||
parent_dir = os.path.dirname(dest_path)
|
||||
archive_name = os.path.basename(dest_path) + ".tar.gz"
|
||||
archive_path = os.path.join(parent_dir, archive_name)
|
||||
|
||||
# Using -C is important to avoid storing the full path in the tarball
|
||||
# Ensure paths with spaces are quoted for the shell script
|
||||
tar_command = f"tar -czf '{archive_path}' -C '{parent_dir}' '{os.path.basename(dest_path)}'"
|
||||
rm_command = f"rm -rf '{dest_path}'"
|
||||
|
||||
script_content = f"""
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
{tar_command}
|
||||
echo \"tar command finished with exit code $?.\"
|
||||
|
||||
{rm_command}
|
||||
echo \"rm command finished with exit code $?.\"
|
||||
"""
|
||||
|
||||
if is_system:
|
||||
self.logger.log("Executing compression and cleanup as root.")
|
||||
if self._execute_as_root(script_content):
|
||||
self.logger.log("Compression and cleanup script executed successfully.")
|
||||
return True
|
||||
else:
|
||||
self.logger.log("Compression and cleanup script failed.")
|
||||
return False
|
||||
else:
|
||||
# For non-system backups, run commands directly
|
||||
try:
|
||||
self.logger.log(f"Executing local command: {tar_command}")
|
||||
tar_result = subprocess.run(tar_command, shell=True, capture_output=True, text=True, check=True)
|
||||
self.logger.log(f"tar command successful. Output: {tar_result.stdout}")
|
||||
|
||||
self.logger.log(f"Executing local command: {rm_command}")
|
||||
rm_result = subprocess.run(rm_command, shell=True, capture_output=True, text=True, check=True)
|
||||
self.logger.log(f"rm command successful. Output: {rm_result.stdout}")
|
||||
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.log(f"A command failed during local compression/cleanup. Return code: {e.returncode}")
|
||||
self.logger.log(f"Stdout: {e.stdout}")
|
||||
self.logger.log(f"Stderr: {e.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.log(f"An unexpected error occurred during local compression/cleanup: {e}")
|
||||
return False
|
||||
|
||||
def _run_backup_path(self, queue, source_path: str, dest_path: str, is_system: bool, is_dry_run: bool, exclude_files: Optional[List[Path]], source_size: int, is_compressed: bool):
|
||||
try:
|
||||
self.is_system_process = is_system
|
||||
self.logger.log(
|
||||
@@ -198,29 +250,52 @@ class BackupManager:
|
||||
|
||||
command.extend([source_path, dest_path])
|
||||
|
||||
self._execute_rsync(queue, command)
|
||||
transferred_size = self._execute_rsync(queue, command)
|
||||
|
||||
if self.process:
|
||||
return_code = self.process.returncode
|
||||
self.logger.log(
|
||||
f"Rsync process finished with return code: {self.process.returncode}")
|
||||
if self.process.returncode == 0 and not is_dry_run:
|
||||
# For user backups, the info file is named after the folder.
|
||||
# For system backups, it's named after the folder inside 'pybackup'.
|
||||
f"Rsync process finished with return code: {return_code}")
|
||||
|
||||
status = 'error'
|
||||
if return_code == 0:
|
||||
status = 'success'
|
||||
elif return_code in [23, 24]: # rsync warnings
|
||||
status = 'warning'
|
||||
elif return_code in [143, -15, 15, -9]: # SIGTERM/SIGKILL
|
||||
status = 'cancelled'
|
||||
|
||||
if status in ['success', 'warning'] and not is_dry_run:
|
||||
info_filename_base = os.path.basename(dest_path)
|
||||
final_size = transferred_size if latest_backup_path else source_size
|
||||
|
||||
if is_compressed:
|
||||
self.logger.log(f"Compression requested for {dest_path}")
|
||||
queue.put(('status_update', 'Phase 2/2: Komprimiere Backup...'))
|
||||
queue.put(('progress_mode', 'indeterminate'))
|
||||
queue.put(('cancel_button_state', 'disabled'))
|
||||
|
||||
if self._compress_and_cleanup(dest_path, is_system):
|
||||
info_filename_base += ".tar.gz"
|
||||
else:
|
||||
self.logger.log("Compression failed, keeping uncompressed backup.")
|
||||
|
||||
queue.put(('progress_mode', 'determinate'))
|
||||
queue.put(('cancel_button_state', 'normal'))
|
||||
|
||||
self._create_info_file(
|
||||
dest_path, f"{info_filename_base}.txt", source_size)
|
||||
else:
|
||||
self.logger.log(
|
||||
"Info file not created due to non-zero return code or dry run.")
|
||||
dest_path, f"{info_filename_base}.txt", final_size)
|
||||
|
||||
queue.put(('completion', {'status': status, 'returncode': return_code}))
|
||||
else:
|
||||
self.logger.log(
|
||||
"Rsync process did not start or self.process is None.")
|
||||
queue.put(('completion', {'status': 'error', 'returncode': -1}))
|
||||
|
||||
self.logger.log(
|
||||
f"Backup to '{dest_path}' completed.")
|
||||
finally:
|
||||
self.process = None
|
||||
queue.put(('completion', None))
|
||||
|
||||
def _create_info_file(self, dest_path: str, filename: str, source_size: int):
|
||||
try:
|
||||
@@ -259,6 +334,7 @@ class BackupManager:
|
||||
f"Failed to create metadata file. Please check permissions for {os.path.dirname(info_file_path)}. Error: {e}")
|
||||
|
||||
def _execute_rsync(self, queue, command: List[str]):
|
||||
transferred_size = 0
|
||||
try:
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
@@ -267,25 +343,27 @@ class BackupManager:
|
||||
self.logger.log(
|
||||
"Error: 'pkexec' or 'rsync' command not found in PATH during Popen call.")
|
||||
queue.put(('error', None))
|
||||
return
|
||||
return 0
|
||||
except Exception as e:
|
||||
self.logger.log(
|
||||
f"Error starting rsync process with Popen: {e}")
|
||||
queue.put(('error', None))
|
||||
return
|
||||
return 0
|
||||
|
||||
if self.process is None: # This check might be redundant if exceptions are caught, but good for safety
|
||||
self.logger.log(
|
||||
"Error: subprocess.Popen returned None for rsync process (after exception handling).")
|
||||
queue.put(('error', None))
|
||||
return # Exit early if process didn't start
|
||||
return 0 # Exit early if process didn't start
|
||||
|
||||
progress_regex = re.compile(r'\s*(\d+)%\s+')
|
||||
output_lines = []
|
||||
|
||||
if self.process.stdout:
|
||||
for line in iter(self.process.stdout.readline, ''):
|
||||
stripped_line = line.strip()
|
||||
self.logger.log(stripped_line)
|
||||
output_lines.append(stripped_line)
|
||||
|
||||
match = progress_regex.search(stripped_line)
|
||||
if match:
|
||||
@@ -300,7 +378,18 @@ class BackupManager:
|
||||
stderr_output = self.process.stderr.read()
|
||||
if stderr_output:
|
||||
self.logger.log(f"Rsync Error: {stderr_output.strip()}")
|
||||
queue.put(('error', None))
|
||||
output_lines.extend(stderr_output.strip().split('\n'))
|
||||
|
||||
# After process completion, parse the output for transferred size
|
||||
for line in output_lines:
|
||||
if line.startswith('Total transferred file size:'):
|
||||
try:
|
||||
size_str = line.split(':')[1].strip().split(' ')[0]
|
||||
transferred_size = int(size_str.replace(',', '').replace('.', ''))
|
||||
self.logger.log(f"Detected transferred size: {transferred_size} bytes")
|
||||
break
|
||||
except (ValueError, IndexError):
|
||||
self.logger.log(f"Could not parse transferred size from line: {line}")
|
||||
|
||||
except FileNotFoundError:
|
||||
self.logger.log(
|
||||
@@ -309,6 +398,55 @@ class BackupManager:
|
||||
except Exception as e:
|
||||
self.logger.log(f"An unexpected error occurred: {e}")
|
||||
queue.put(('error', None))
|
||||
|
||||
return transferred_size
|
||||
|
||||
def start_restore(self, source_path: str, dest_path: str, is_compressed: bool):
|
||||
"""Starts a restore process in a separate thread."""
|
||||
# We need the queue from the app instance to report progress
|
||||
# A bit of a hack, but avoids passing the queue all the way down from the UI
|
||||
try:
|
||||
queue = self.app.queue
|
||||
except AttributeError:
|
||||
self.logger.log("Could not get queue from app instance. Restore progress will not be reported.")
|
||||
# Create a dummy queue
|
||||
from queue import Queue
|
||||
queue = Queue()
|
||||
|
||||
thread = threading.Thread(target=self._run_restore, args=(
|
||||
queue, source_path, dest_path, is_compressed))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def _run_restore(self, queue, source_path: str, dest_path: str, is_compressed: bool):
|
||||
"""Executes the restore logic for a system backup."""
|
||||
self.logger.log(f"Starting restore from {source_path} to {dest_path}")
|
||||
status = 'error'
|
||||
try:
|
||||
if is_compressed:
|
||||
# For compressed files, we extract to the destination.
|
||||
# The -C flag tells tar to change to that directory before extracting.
|
||||
script_content = f"tar -xzf '{source_path}' -C '{dest_path}'"
|
||||
else:
|
||||
# For regular directories, we rsync the content.
|
||||
# Ensure source path has a trailing slash to copy contents.
|
||||
source = source_path.rstrip('/') + '/'
|
||||
script_content = f"rsync -aAXHv '{source}' '{dest_path}'"
|
||||
|
||||
if self._execute_as_root(script_content):
|
||||
self.logger.log("Restore script executed successfully.")
|
||||
status = 'success'
|
||||
else:
|
||||
self.logger.log("Restore script failed.")
|
||||
status = 'error'
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log(f"An unexpected error occurred during restore: {e}")
|
||||
status = 'error'
|
||||
finally:
|
||||
# Use a generic completion message for now.
|
||||
# The queue processing logic in main_app might need a 'restore_completion' type.
|
||||
queue.put(('completion', {'status': status, 'returncode': 0 if status == 'success' else 1}))
|
||||
|
||||
def get_scheduled_jobs(self) -> List[Dict[str, Any]]:
|
||||
jobs_list = []
|
||||
@@ -390,36 +528,42 @@ class BackupManager:
|
||||
if not os.path.isdir(pybackup_path):
|
||||
return system_backups
|
||||
|
||||
# Regex to parse folder names like '6-März-2024_system_full'
|
||||
# Regex to parse folder names like '6-März-2024_system_full' or '6-März-2024_system_full.tar.gz'
|
||||
name_regex = re.compile(
|
||||
r"^(\d{1,2}-\w+-\d{4})_system_(full|incremental)$", re.IGNORECASE)
|
||||
r"^(\d{1,2}-\w+-\d{4})_system_(full|incremental)(\.tar\.gz)?$", re.IGNORECASE)
|
||||
|
||||
for item in os.listdir(pybackup_path):
|
||||
full_path = os.path.join(pybackup_path, item)
|
||||
if not os.path.isdir(full_path):
|
||||
# Skip info files
|
||||
if item.endswith('.txt'):
|
||||
continue
|
||||
|
||||
match = name_regex.match(item)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
full_path = os.path.join(pybackup_path, item)
|
||||
date_str = match.group(1)
|
||||
backup_type = match.group(2).capitalize()
|
||||
backup_type_base = match.group(2).capitalize()
|
||||
is_compressed = match.group(3) is not None
|
||||
|
||||
backup_type = backup_type_base
|
||||
if is_compressed:
|
||||
backup_type += " (Compressed)"
|
||||
|
||||
backup_size = "N/A"
|
||||
comment = ""
|
||||
|
||||
# NEW: Look for info file in the parent directory, named after the backup folder
|
||||
# Info file is named after the backup item (e.g., 'backup_name.txt' or 'backup_name.tar.gz.txt')
|
||||
info_file_path = os.path.join(pybackup_path, f"{item}.txt")
|
||||
if os.path.exists(info_file_path):
|
||||
try:
|
||||
with open(info_file_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip().lower().startswith("originalgröße:"):
|
||||
# Extract size, e.g., "Originalgröße: 13.45 GB (...)"
|
||||
size_match = re.search(r":\s*(.*?)\s*\(", line)
|
||||
if size_match:
|
||||
backup_size = size_match.group(1).strip()
|
||||
else: # Fallback if format is just "Originalgröße: 13.45 GB"
|
||||
else:
|
||||
backup_size = line.split(":")[1].strip()
|
||||
elif line.strip().lower().startswith("kommentar:"):
|
||||
comment = line.split(":", 1)[1].strip()
|
||||
@@ -433,7 +577,8 @@ class BackupManager:
|
||||
"size": backup_size,
|
||||
"folder_name": item,
|
||||
"full_path": full_path,
|
||||
"comment": comment
|
||||
"comment": comment,
|
||||
"is_compressed": is_compressed
|
||||
})
|
||||
|
||||
# Sort by parsing the date from the folder name
|
||||
|
||||
74
main_app.py
74
main_app.py
@@ -20,7 +20,6 @@ from core.data_processing import DataProcessing
|
||||
from pyimage_ui.drawing import Drawing
|
||||
from pyimage_ui.navigation import Navigation
|
||||
from pyimage_ui.actions import Actions
|
||||
from pyimage_ui.shared_logic import enforce_backup_type_exclusivity
|
||||
|
||||
|
||||
class MainApplication(tk.Tk):
|
||||
@@ -422,13 +421,16 @@ class MainApplication(tk.Tk):
|
||||
self.time_info_frame = ttk.Frame(self.info_checkbox_frame)
|
||||
self.time_info_frame.pack(anchor=tk.W, fill=tk.X, pady=5)
|
||||
|
||||
self.start_time_label = ttk.Label(self.time_info_frame, text="Start: --:--:--")
|
||||
self.start_time_label = ttk.Label(
|
||||
self.time_info_frame, text="Start: --:--:--")
|
||||
self.start_time_label.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.end_time_label = ttk.Label(self.time_info_frame, text="Ende: --:--:--")
|
||||
|
||||
self.end_time_label = ttk.Label(
|
||||
self.time_info_frame, text="Ende: --:--:--")
|
||||
self.end_time_label.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.duration_label = ttk.Label(self.time_info_frame, text="Dauer: --:--:--")
|
||||
self.duration_label = ttk.Label(
|
||||
self.time_info_frame, text="Dauer: --:--:--")
|
||||
self.duration_label.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
# --- Accurate Size Calculation Frame (on the right) ---
|
||||
@@ -436,10 +438,11 @@ class MainApplication(tk.Tk):
|
||||
accurate_size_frame.pack(side=tk.LEFT, padx=20)
|
||||
|
||||
self.accurate_size_cb = ttk.Checkbutton(accurate_size_frame, text=Msg.STR["accurate_size_cb_label"],
|
||||
variable=self.genaue_berechnung_var, command=self.actions.on_toggle_accurate_size_calc)
|
||||
variable=self.genaue_berechnung_var, command=self.actions.on_toggle_accurate_size_calc)
|
||||
self.accurate_size_cb.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
accurate_size_info_label = ttk.Label(accurate_size_frame, text=Msg.STR["accurate_size_info_label"], foreground="gray")
|
||||
accurate_size_info_label = ttk.Label(
|
||||
accurate_size_frame, text=Msg.STR["accurate_size_info_label"], foreground="gray")
|
||||
accurate_size_info_label.pack(side=tk.LEFT)
|
||||
|
||||
checkbox_frame = ttk.Frame(self.info_checkbox_frame)
|
||||
@@ -453,7 +456,7 @@ class MainApplication(tk.Tk):
|
||||
self.incremental_cb.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.compressed_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["compressed"],
|
||||
variable=self.compressed_var)
|
||||
variable=self.compressed_var, command=self.actions.handle_compression_change)
|
||||
self.compressed_cb.pack(side=tk.LEFT, padx=5)
|
||||
self.encrypted_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["encrypted"],
|
||||
variable=self.encrypted_var)
|
||||
@@ -550,29 +553,57 @@ class MainApplication(tk.Tk):
|
||||
while True:
|
||||
message = self.queue.get_nowait()
|
||||
|
||||
# Check if it's a backup status message (2-element tuple)
|
||||
if isinstance(message, tuple) and len(message) == 2:
|
||||
message_type, value = message
|
||||
|
||||
if message_type == 'progress':
|
||||
self.task_progress["value"] = value
|
||||
self.info_label.config(text=f"Fortschritt: {value}%") # Update progress text
|
||||
self.info_label.config(text=f"Fortschritt: {value}%")
|
||||
elif message_type == 'file_update':
|
||||
# Truncate long text to avoid window resizing issues
|
||||
max_len = 120
|
||||
if len(value) > max_len:
|
||||
value = "..." + value[-max_len:]
|
||||
self.current_file_label.config(text=value)
|
||||
elif message_type == 'status_update':
|
||||
self.info_label.config(text=value)
|
||||
elif message_type == 'progress_mode':
|
||||
self.task_progress.config(mode=value)
|
||||
if value == 'indeterminate':
|
||||
self.task_progress.start()
|
||||
else:
|
||||
self.task_progress.stop()
|
||||
elif message_type == 'cancel_button_state':
|
||||
self.start_pause_button.config(state=value)
|
||||
elif message_type == 'error':
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.start_pause_button["text"] = "Start"
|
||||
self.backup_is_running = False
|
||||
elif message_type == 'completion':
|
||||
status_info = value
|
||||
status = 'error' # Default
|
||||
if isinstance(status_info, dict):
|
||||
status = status_info.get('status', 'error')
|
||||
elif status_info is None: # Fallback for older logic
|
||||
status = 'success'
|
||||
|
||||
if status == 'success':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["backup_finished_successfully"])
|
||||
elif status == 'warning':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["backup_finished_with_warnings"])
|
||||
elif status == 'error':
|
||||
self.info_label.config(
|
||||
text=Msg.STR["backup_failed"])
|
||||
elif status == 'cancelled':
|
||||
# This is handled in actions.py, but we clean up here.
|
||||
pass
|
||||
|
||||
self.animated_icon.stop("DISABLE")
|
||||
self.start_pause_button["text"] = "Start"
|
||||
self.task_progress["value"] = 0
|
||||
self.current_file_label.config(text="Backup finished.")
|
||||
|
||||
self.current_file_label.config(text="")
|
||||
|
||||
if self.start_time:
|
||||
end_time = datetime.datetime.now()
|
||||
duration = end_time - self.start_time
|
||||
@@ -582,26 +613,27 @@ class MainApplication(tk.Tk):
|
||||
duration_str = f"{hours:02}:{minutes:02}:{seconds:02}"
|
||||
end_str = end_time.strftime("%H:%M:%S")
|
||||
self.end_time_label.config(text=f"Ende: {end_str}")
|
||||
self.duration_label.config(text=f"Dauer: {duration_str}")
|
||||
self.duration_label.config(
|
||||
text=f"Dauer: {duration_str}")
|
||||
self.start_time = None
|
||||
|
||||
self.backup_is_running = False
|
||||
self.actions._set_ui_state(True) # Re-enable UI
|
||||
self.actions._set_ui_state(True)
|
||||
elif message_type == 'completion_accurate':
|
||||
if value == 'success':
|
||||
self.current_file_label.config(text=Msg.STR["accurate_size_success"])
|
||||
self.info_label.config(
|
||||
text=Msg.STR["accurate_size_success"])
|
||||
else:
|
||||
self.current_file_label.config(text=Msg.STR["accurate_size_failed"])
|
||||
self.actions._set_ui_state(True) # Re-enable UI
|
||||
self.info_label.config(
|
||||
text=Msg.STR["accurate_size_failed"])
|
||||
self.actions._set_ui_state(True)
|
||||
else:
|
||||
# This message is not for us (likely for DataProcessing), put it back and yield.
|
||||
self.queue.put(message)
|
||||
break
|
||||
|
||||
except Empty:
|
||||
pass # Queue is empty, do nothing
|
||||
pass
|
||||
|
||||
# Reschedule the queue check
|
||||
self.after(100, self._process_backup_queue)
|
||||
|
||||
def quit(self):
|
||||
|
||||
@@ -257,6 +257,10 @@ class Msg:
|
||||
"cancel_backup": _("Cancel"),
|
||||
"backup_cancelled_and_deleted_msg": _("Backup cancelled and partially completed backup deleted."),
|
||||
"info_text_placeholder": _("Info text about the current view."),
|
||||
"backup_finished_successfully": _("Backup finished successfully."),
|
||||
"backup_finished_with_warnings": _("Backup finished with warnings. See log for details."),
|
||||
"backup_failed": _("Backup failed. See log for details."),
|
||||
"backup_cancelled_by_user": _("Backup was cancelled by the user."),
|
||||
"accurate_size_cb_label": _("Accurate inkrem. size"),
|
||||
"accurate_size_info_label": _("(Calculation may take longer)"),
|
||||
"accurate_size_success": _("Accurate size calculated successfully."),
|
||||
@@ -302,7 +306,9 @@ class Msg:
|
||||
"err_no_source_folder": _("Please select at least one source folder."),
|
||||
"err_no_backup_selected": _("Please select a backup from the list."),
|
||||
"confirm_user_restore_title": _("Confirm User Data Restore"),
|
||||
"confirm_user_restore_msg": _("Do you really want to restore the backup of '{backup_name}' to its original location? \n\nAny newer files may be overwritten."),
|
||||
"confirm_user_restore_msg": _("Do you really want to restore the backup of '{backup_name}' to its original location? Any newer files may be overwritten."),
|
||||
"confirm_delete_title": _("Confirm Deletion"),
|
||||
"confirm_delete_text": _("Do you really want to delete the backup '{folder_name}'? This action cannot be undone."),
|
||||
"final_warning_system_restore_title": _("FINAL WARNING"),
|
||||
"final_warning_system_restore_msg": _("ATTENTION: You are about to restore the system. This process cannot be safely interrupted. All changes since the backup will be lost. \n\nThe computer will automatically restart upon completion. \n\nREALLY PROCEED?"),
|
||||
"btn_continue": _("PROCEED"),
|
||||
|
||||
@@ -47,28 +47,30 @@ class Actions:
|
||||
|
||||
if full_backup_exists:
|
||||
# Case 1: A full backup exists. Allow user to choose, default to incremental.
|
||||
if not self.app.vollbackup_var.get(): # If user has not manually selected full backup
|
||||
if not self.app.vollbackup_var.get(): # If user has not manually selected full backup
|
||||
self.app.vollbackup_var.set(False)
|
||||
self.app.inkrementell_var.set(True)
|
||||
self.app.full_backup_cb.config(state="normal")
|
||||
self.app.incremental_cb.config(state="normal")
|
||||
self.app.accurate_size_cb.config(state="normal") # Enable accurate calc
|
||||
self.app.accurate_size_cb.config(
|
||||
state="normal") # Enable accurate calc
|
||||
else:
|
||||
# Case 2: No full backup exists. Force a full backup.
|
||||
self.app.vollbackup_var.set(True)
|
||||
self.app.inkrementell_var.set(False)
|
||||
self.app.full_backup_cb.config(state="disabled")
|
||||
self.app.incremental_cb.config(state="disabled")
|
||||
self.app.accurate_size_cb.config(state="disabled") # Disable accurate calc
|
||||
self.app.accurate_size_cb.config(
|
||||
state="disabled") # Disable accurate calc
|
||||
|
||||
def handle_backup_type_change(self, changed_var_name):
|
||||
# This function is called when the user clicks on "Full" or "Incremental".
|
||||
# It enforces that only one can be selected and updates the accurate size checkbox accordingly.
|
||||
if changed_var_name == 'voll':
|
||||
if self.app.vollbackup_var.get(): # if "Full" was checked
|
||||
if self.app.vollbackup_var.get(): # if "Full" was checked
|
||||
self.app.inkrementell_var.set(False)
|
||||
elif changed_var_name == 'inkrementell':
|
||||
if self.app.inkrementell_var.get(): # if "Incremental" was checked
|
||||
if self.app.inkrementell_var.get(): # if "Incremental" was checked
|
||||
self.app.vollbackup_var.set(False)
|
||||
|
||||
# Now, update the state of the accurate calculation checkbox
|
||||
@@ -82,6 +84,22 @@ class Actions:
|
||||
else:
|
||||
self.app.accurate_size_cb.config(state="disabled")
|
||||
|
||||
def handle_compression_change(self):
|
||||
if self.app.compressed_var.get():
|
||||
# Compression is enabled, force full backup
|
||||
self.app.vollbackup_var.set(True)
|
||||
self.app.inkrementell_var.set(False)
|
||||
self.app.full_backup_cb.config(state="disabled")
|
||||
self.app.incremental_cb.config(state="disabled")
|
||||
# Also disable accurate incremental size calculation, as it's irrelevant
|
||||
self.app.accurate_size_cb.config(state="disabled")
|
||||
self.app.genaue_berechnung_var.set(False)
|
||||
else:
|
||||
# Compression is disabled, restore normal logic
|
||||
self.app.full_backup_cb.config(state="normal")
|
||||
# The state of the incremental checkbox depends on whether a full backup exists
|
||||
self._update_backup_type_controls()
|
||||
|
||||
def on_toggle_accurate_size_calc(self):
|
||||
# This method is called when the user clicks the "Genaue inkrem. Größe" checkbox.
|
||||
if not self.app.genaue_berechnung_var.get():
|
||||
@@ -97,14 +115,16 @@ class Actions:
|
||||
# --- Start the accurate calculation ---
|
||||
app_logger.log("Accurate incremental size calculation requested.")
|
||||
self.app.accurate_calculation_running = True
|
||||
self._set_ui_state(False, keep_cancel_enabled=True) # Lock the UI, keep Cancel enabled
|
||||
# Lock the UI, keep Cancel enabled
|
||||
self._set_ui_state(False, keep_cancel_enabled=True)
|
||||
self.app.start_pause_button.config(text=Msg.STR["cancel_backup"])
|
||||
|
||||
# Immediately clear the projection canvases
|
||||
self.app.drawing.reset_projection_canvases()
|
||||
|
||||
# Update canvas to show it's calculating and start animation
|
||||
self.app.info_label.config(text=Msg.STR["please_wait"], foreground="#0078d7")
|
||||
self.app.info_label.config(
|
||||
text=Msg.STR["please_wait"], foreground="#0078d7")
|
||||
self.app.task_progress.config(mode="indeterminate")
|
||||
self.app.task_progress.start()
|
||||
self.app.left_canvas_data.update({
|
||||
@@ -119,26 +139,29 @@ class Actions:
|
||||
button_text = self.app.left_canvas_data.get('folder')
|
||||
|
||||
if not folder_path or not button_text:
|
||||
app_logger.log("Cannot start accurate calculation, source folder info missing.")
|
||||
self._set_ui_state(True) # Unlock UI
|
||||
self.app.genaue_berechnung_var.set(False) # Uncheck the box
|
||||
app_logger.log(
|
||||
"Cannot start accurate calculation, source folder info missing.")
|
||||
self._set_ui_state(True) # Unlock UI
|
||||
self.app.genaue_berechnung_var.set(False) # Uncheck the box
|
||||
self.app.accurate_calculation_running = False
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
return
|
||||
|
||||
def threaded_incremental_calc():
|
||||
status = 'failure' # Default to failure
|
||||
status = 'failure' # Default to failure
|
||||
size = 0
|
||||
try:
|
||||
exclude_file_paths = []
|
||||
if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.GENERATED_EXCLUDE_LIST_PATH)
|
||||
exclude_file_paths.append(
|
||||
AppConfig.GENERATED_EXCLUDE_LIST_PATH)
|
||||
if AppConfig.USER_EXCLUDE_LIST_PATH.exists():
|
||||
exclude_file_paths.append(AppConfig.USER_EXCLUDE_LIST_PATH)
|
||||
|
||||
base_dest = self.app.destination_path
|
||||
correct_parent_dir = os.path.join(base_dest, "pybackup")
|
||||
dummy_dest_for_calc = os.path.join(correct_parent_dir, "dummy_name")
|
||||
dummy_dest_for_calc = os.path.join(
|
||||
correct_parent_dir, "dummy_name")
|
||||
|
||||
size = self.app.data_processing.get_incremental_backup_size(
|
||||
source_path=folder_path,
|
||||
@@ -154,16 +177,19 @@ class Actions:
|
||||
finally:
|
||||
# This message MUST be sent to unlock the UI and update the display.
|
||||
# It is now handled by the main data_processing queue.
|
||||
if self.app.accurate_calculation_running: # Only post if not cancelled
|
||||
self.app.queue.put((button_text, size, self.app.mode, 'accurate_incremental', status))
|
||||
if self.app.accurate_calculation_running: # Only post if not cancelled
|
||||
self.app.queue.put(
|
||||
(button_text, size, self.app.mode, 'accurate_incremental', status))
|
||||
|
||||
self.app.calculation_thread = threading.Thread(target=threaded_incremental_calc)
|
||||
self.app.calculation_thread = threading.Thread(
|
||||
target=threaded_incremental_calc)
|
||||
self.app.calculation_thread.daemon = True
|
||||
self.app.calculation_thread.start()
|
||||
|
||||
def on_sidebar_button_click(self, button_text):
|
||||
if self.app.backup_is_running or self.app.accurate_calculation_running:
|
||||
app_logger.log("Action blocked: Backup or accurate calculation is in progress.")
|
||||
app_logger.log(
|
||||
"Action blocked: Backup or accurate calculation is in progress.")
|
||||
return
|
||||
|
||||
self.app.drawing.reset_projection_canvases()
|
||||
@@ -273,7 +299,8 @@ class Actions:
|
||||
# For system backup, now we default to the simple, fast (but for incremental inaccurate) full size calculation.
|
||||
# The accurate calculation is now triggered explicitly by the new checkbox.
|
||||
if button_text == "Computer":
|
||||
app_logger.log("Using default (full) size calculation for system backup display.")
|
||||
app_logger.log(
|
||||
"Using default (full) size calculation for system backup display.")
|
||||
exclude_patterns = self.app.data_processing.load_exclude_patterns()
|
||||
target_method = self.app.data_processing.get_folder_size_threaded
|
||||
args = (folder_path, button_text, self.app.calculation_stop_event,
|
||||
@@ -417,15 +444,15 @@ class Actions:
|
||||
"""Parses a size string like '38.61 GB' into bytes."""
|
||||
if not size_str or size_str == Msg.STR["calculating_size"]:
|
||||
return 0
|
||||
|
||||
|
||||
parts = size_str.split()
|
||||
if len(parts) != 2:
|
||||
return 0 # Invalid format
|
||||
return 0 # Invalid format
|
||||
|
||||
try:
|
||||
value = float(parts[0])
|
||||
unit = parts[1].upper()
|
||||
|
||||
|
||||
if unit == 'B':
|
||||
return int(value)
|
||||
elif unit == 'KB':
|
||||
@@ -494,7 +521,7 @@ class Actions:
|
||||
]
|
||||
for cb in checkboxes:
|
||||
cb.config(state="disabled")
|
||||
|
||||
|
||||
# Special handling for the cancel button during accurate calculation
|
||||
if keep_cancel_enabled:
|
||||
self.app.start_pause_button.config(state="normal")
|
||||
@@ -505,7 +532,7 @@ class Actions:
|
||||
app_logger.log("Accurate size calculation cancelled by user.")
|
||||
self.app.accurate_calculation_running = False
|
||||
self.app.genaue_berechnung_var.set(False)
|
||||
|
||||
|
||||
# Stop all animations and restore UI
|
||||
self.app.animated_icon.stop("DISABLE")
|
||||
if self.app.left_canvas_animation:
|
||||
@@ -516,7 +543,9 @@ class Actions:
|
||||
# Restore bottom action bar
|
||||
self.app.task_progress.stop()
|
||||
self.app.task_progress.config(mode="determinate", value=0)
|
||||
self.app.info_label.config(text=Msg.STR["accurate_calc_cancelled"], foreground="#E8740C") # Orange for cancelled
|
||||
self.app.info_label.config(
|
||||
# Orange for cancelled
|
||||
text=Msg.STR["accurate_calc_cancelled"], foreground="#E8740C")
|
||||
self.app.start_pause_button.config(text=Msg.STR["start"])
|
||||
self._set_ui_state(True)
|
||||
return
|
||||
@@ -578,7 +607,7 @@ class Actions:
|
||||
return
|
||||
|
||||
self.app.backup_is_running = True
|
||||
|
||||
|
||||
# --- Record and Display Start Time ---
|
||||
self.app.start_time = datetime.datetime.now()
|
||||
start_str = self.app.start_time.strftime("%H:%M:%S")
|
||||
@@ -625,8 +654,10 @@ class Actions:
|
||||
app_logger.log(
|
||||
"Could not set locale to de_DE.UTF-8. Using default.")
|
||||
|
||||
date_str = datetime.datetime.now().strftime("%d-%B-%Y")
|
||||
folder_name = f"{date_str}_system_{mode}"
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%d-%B-%Y")
|
||||
time_str = now.strftime("%H%M%S")
|
||||
folder_name = f"{date_str}_{time_str}_system_{mode}"
|
||||
final_dest = os.path.join(base_dest, "pybackup", folder_name)
|
||||
# Store the path for potential deletion
|
||||
self.app.current_backup_path = final_dest
|
||||
@@ -640,6 +671,8 @@ class Actions:
|
||||
exclude_file_paths.append(AppConfig.USER_EXCLUDE_LIST_PATH)
|
||||
|
||||
is_dry_run = self.app.testlauf_var.get()
|
||||
is_compressed = self.app.compressed_var.get()
|
||||
|
||||
self.app.backup_manager.start_backup(
|
||||
queue=self.app.queue,
|
||||
source_path="/",
|
||||
@@ -647,7 +680,8 @@ class Actions:
|
||||
is_system=True,
|
||||
is_dry_run=is_dry_run,
|
||||
exclude_files=exclude_file_paths,
|
||||
source_size=source_size_bytes)
|
||||
source_size=source_size_bytes,
|
||||
is_compressed=is_compressed)
|
||||
|
||||
def _start_user_backup(self, sources):
|
||||
dest = self.app.destination_path
|
||||
|
||||
@@ -10,6 +10,7 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
def __init__(self, master, backup_manager, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
self.backup_manager = backup_manager
|
||||
self.system_backups_list = []
|
||||
|
||||
self.backup_path = None
|
||||
|
||||
@@ -44,17 +45,17 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
|
||||
list_button_frame = ttk.Frame(self.content_frame)
|
||||
list_button_frame.pack(pady=10)
|
||||
|
||||
|
||||
self.restore_button = ttk.Button(list_button_frame, text=Msg.STR["restore"],
|
||||
command=self._restore_selected, state="disabled")
|
||||
command=self._restore_selected, state="disabled")
|
||||
self.restore_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
|
||||
self.delete_button = ttk.Button(list_button_frame, text=Msg.STR["delete"],
|
||||
command=self._delete_selected, state="disabled")
|
||||
command=self._delete_selected, state="disabled")
|
||||
self.delete_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
self.edit_comment_button = ttk.Button(list_button_frame, text="Kommentar bearbeiten",
|
||||
command=self._edit_comment, state="disabled")
|
||||
command=self._edit_comment, state="disabled")
|
||||
self.edit_comment_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def show(self, backup_path):
|
||||
@@ -84,22 +85,25 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
backup_info.get("comment", ""),
|
||||
backup_info.get("folder_name", "N/A")
|
||||
))
|
||||
self._on_item_select(None) # Disable buttons initially
|
||||
self._on_item_select(None) # Disable buttons initially
|
||||
|
||||
def _on_item_select(self, event):
|
||||
selected_item = self.content_tree.focus()
|
||||
is_selected = True if selected_item else False
|
||||
self.restore_button.config(state="normal" if is_selected else "disabled")
|
||||
self.delete_button.config(state="normal" if is_selected else "disabled")
|
||||
self.edit_comment_button.config(state="normal" if is_selected else "disabled")
|
||||
self.restore_button.config(
|
||||
state="normal" if is_selected else "disabled")
|
||||
self.delete_button.config(
|
||||
state="normal" if is_selected else "disabled")
|
||||
self.edit_comment_button.config(
|
||||
state="normal" if is_selected else "disabled")
|
||||
|
||||
def _edit_comment(self):
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
|
||||
|
||||
item_values = self.content_tree.item(selected_item)["values"]
|
||||
folder_name = item_values[4] # Assuming folder_name is the 5th value
|
||||
folder_name = item_values[4] # Assuming folder_name is the 5th value
|
||||
|
||||
# Construct the path to the info file
|
||||
pybackup_path = os.path.join(self.backup_path, "pybackup")
|
||||
@@ -109,17 +113,49 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
if not os.path.exists(info_file_path):
|
||||
# If for some reason the info file is missing, we can create an empty one.
|
||||
self.backup_manager.update_comment(info_file_path, "")
|
||||
|
||||
|
||||
CommentEditorDialog(self, info_file_path, self.backup_manager)
|
||||
self._load_backup_content() # Refresh list to show new comment
|
||||
self._load_backup_content() # Refresh list to show new comment
|
||||
|
||||
def _restore_selected(self):
|
||||
# Placeholder for restore logic
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
backup_name = self.content_tree.item(selected_item)["values"][0]
|
||||
print(f"Restoring {backup_name}...")
|
||||
|
||||
item_values = self.content_tree.item(selected_item)["values"]
|
||||
folder_name = item_values[4] # 5th column is folder_name
|
||||
|
||||
selected_backup = None
|
||||
for backup in self.system_backups_list:
|
||||
if backup.get("folder_name") == folder_name:
|
||||
selected_backup = backup
|
||||
break
|
||||
|
||||
if not selected_backup:
|
||||
print(f"Error: Could not find backup info for {folder_name}")
|
||||
return
|
||||
|
||||
# We need to get the restore destination from the main app
|
||||
# This is a bit tricky as this frame is isolated.
|
||||
# We assume the main app has a way to provide this.
|
||||
# Let's get it from the config manager, which should be accessible via the backup_manager's app instance.
|
||||
try:
|
||||
# Accessing the app instance through the master hierarchy
|
||||
main_app = self.winfo_toplevel()
|
||||
restore_dest_path = main_app.config_manager.get_setting("restore_destination_path", "/")
|
||||
|
||||
if not restore_dest_path:
|
||||
print("Error: Restore destination not set.")
|
||||
# Optionally, show a message box to the user
|
||||
return
|
||||
|
||||
self.backup_manager.start_restore(
|
||||
source_path=selected_backup['full_path'],
|
||||
dest_path=restore_dest_path,
|
||||
is_compressed=selected_backup['is_compressed']
|
||||
)
|
||||
except AttributeError:
|
||||
print("Could not access main application instance to get restore path.")
|
||||
|
||||
def _delete_selected(self):
|
||||
selected_item = self.content_tree.focus()
|
||||
@@ -133,14 +169,5 @@ class SystemBackupContentFrame(ttk.Frame):
|
||||
pybackup_path = os.path.join(self.backup_path, "pybackup")
|
||||
folder_to_delete = os.path.join(pybackup_path, folder_name)
|
||||
|
||||
# Ask for confirmation
|
||||
from shared_libs.message import MessageDialog
|
||||
dialog = MessageDialog(master=self, message_type="warning",
|
||||
title=Msg.STR["confirm_delete_title"],
|
||||
text=Msg.STR["confirm_delete_text"].format(folder_name=folder_name),
|
||||
buttons=["ok_cancel"])
|
||||
if dialog.get_result() != "ok":
|
||||
return
|
||||
|
||||
self.backup_manager.delete_privileged_path(folder_to_delete)
|
||||
self._load_backup_content()
|
||||
|
||||
@@ -41,13 +41,13 @@ class UserBackupContentFrame(ttk.Frame):
|
||||
list_button_frame = ttk.Frame(self.content_frame)
|
||||
list_button_frame.pack(pady=10)
|
||||
self.restore_button = ttk.Button(list_button_frame, text=Msg.STR["restore"],
|
||||
command=self._restore_selected, state="disabled")
|
||||
command=self._restore_selected, state="disabled")
|
||||
self.restore_button.pack(side=tk.LEFT, padx=5)
|
||||
self.delete_button = ttk.Button(list_button_frame, text=Msg.STR["delete"],
|
||||
command=self._delete_selected, state="disabled")
|
||||
command=self._delete_selected, state="disabled")
|
||||
self.delete_button.pack(side=tk.LEFT, padx=5)
|
||||
self.edit_comment_button = ttk.Button(list_button_frame, text="Kommentar bearbeiten",
|
||||
command=self._edit_comment, state="disabled")
|
||||
command=self._edit_comment, state="disabled")
|
||||
self.edit_comment_button.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def show(self, backup_path):
|
||||
@@ -79,26 +79,29 @@ class UserBackupContentFrame(ttk.Frame):
|
||||
def _on_item_select(self, event):
|
||||
selected_item = self.content_tree.focus()
|
||||
is_selected = True if selected_item else False
|
||||
self.restore_button.config(state="normal" if is_selected else "disabled")
|
||||
self.delete_button.config(state="normal" if is_selected else "disabled")
|
||||
self.edit_comment_button.config(state="normal" if is_selected else "disabled")
|
||||
self.restore_button.config(
|
||||
state="normal" if is_selected else "disabled")
|
||||
self.delete_button.config(
|
||||
state="normal" if is_selected else "disabled")
|
||||
self.edit_comment_button.config(
|
||||
state="normal" if is_selected else "disabled")
|
||||
|
||||
def _edit_comment(self):
|
||||
selected_item = self.content_tree.focus()
|
||||
if not selected_item:
|
||||
return
|
||||
|
||||
|
||||
item_values = self.content_tree.item(selected_item)["values"]
|
||||
folder_name = item_values[3] # Assuming folder_name is the 4th value
|
||||
folder_name = item_values[3] # Assuming folder_name is the 4th value
|
||||
|
||||
# Construct the path to the info file
|
||||
info_file_path = os.path.join(self.backup_path, f"{folder_name}.txt")
|
||||
|
||||
if not os.path.exists(info_file_path):
|
||||
self.backup_manager.update_comment(info_file_path, "")
|
||||
|
||||
|
||||
CommentEditorDialog(self, info_file_path, self.backup_manager)
|
||||
self._load_backup_content() # Refresh list to show new comment
|
||||
self._load_backup_content() # Refresh list to show new comment
|
||||
|
||||
def _restore_selected(self):
|
||||
# Placeholder for restore logic
|
||||
@@ -118,13 +121,15 @@ class UserBackupContentFrame(ttk.Frame):
|
||||
|
||||
# Construct the full path to the backup folder
|
||||
folder_to_delete = os.path.join(self.backup_path, folder_name)
|
||||
info_file_to_delete = os.path.join(self.backup_path, f"{folder_name}.txt")
|
||||
info_file_to_delete = os.path.join(
|
||||
self.backup_path, f"{folder_name}.txt")
|
||||
|
||||
# Ask for confirmation
|
||||
from shared_libs.message import MessageDialog
|
||||
dialog = MessageDialog(master=self, message_type="warning",
|
||||
title=Msg.STR["confirm_delete_title"],
|
||||
text=Msg.STR["confirm_delete_text"].format(folder_name=folder_name),
|
||||
text=Msg.STR["confirm_delete_text"].format(
|
||||
folder_name=folder_name),
|
||||
buttons=["ok_cancel"])
|
||||
if dialog.get_result() != "ok":
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user