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:
2025-08-31 23:57:00 +02:00
parent f69e77cdd2
commit b6b05633a7
6 changed files with 362 additions and 113 deletions

View File

@@ -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

View File

@@ -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):

View File

@@ -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"),

View File

@@ -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

View File

@@ -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()

View File

@@ -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