diff --git a/backup_manager.py b/backup_manager.py index bb75cd2..64ad4c7 100644 --- a/backup_manager.py +++ b/backup_manager.py @@ -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 diff --git a/main_app.py b/main_app.py index 64a4535..2ef8ae2 100644 --- a/main_app.py +++ b/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): diff --git a/pbp_app_config.py b/pbp_app_config.py index adecd2e..eddd048 100644 --- a/pbp_app_config.py +++ b/pbp_app_config.py @@ -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"), diff --git a/pyimage_ui/actions.py b/pyimage_ui/actions.py index 644ea3d..a0cfb50 100644 --- a/pyimage_ui/actions.py +++ b/pyimage_ui/actions.py @@ -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 diff --git a/pyimage_ui/system_backup_content_frame.py b/pyimage_ui/system_backup_content_frame.py index 4f74c6f..7db84d5 100644 --- a/pyimage_ui/system_backup_content_frame.py +++ b/pyimage_ui/system_backup_content_frame.py @@ -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() diff --git a/pyimage_ui/user_backup_content_frame.py b/pyimage_ui/user_backup_content_frame.py index f62081e..d63d1ee 100644 --- a/pyimage_ui/user_backup_content_frame.py +++ b/pyimage_ui/user_backup_content_frame.py @@ -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