diff --git a/backup_manager.py b/backup_manager.py index 64d9c74..c76169a 100644 --- a/backup_manager.py +++ b/backup_manager.py @@ -299,7 +299,11 @@ set -e 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 is None: # This was a full backup + final_size = source_size + else: # This was an incremental backup + final_size = transferred_size if is_compressed: self.logger.log(f"Compression requested for {dest_path}") @@ -369,8 +373,11 @@ set -e transferred_size = 0 try: try: + # Force C locale to ensure rsync output is in English for parsing + env = os.environ.copy() + env['LC_ALL'] = 'C' self.process = subprocess.Popen( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, preexec_fn=os.setsid) + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, preexec_fn=os.setsid, env=env) except FileNotFoundError: self.logger.log( "Error: 'pkexec' or 'rsync' command not found in PATH during Popen call.") @@ -412,16 +419,53 @@ set -e self.logger.log(f"Rsync Error: {stderr_output.strip()}") 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:'): + # After process completion, parse the output for transferred size. + # This is tricky because the output format can vary. We'll try to find the + # summary line from --info=progress2, which looks like "sent X bytes received Y bytes". + transferred_size = 0 + summary_regex = re.compile(r"sent ([\d,.]+) bytes\s+received ([\d,.]+) bytes") + + for line in reversed(output_lines): # Search from the end, as summary is usually last + match = summary_regex.search(line) + if match: 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}") + sent_str = match.group(1).replace(',', '').replace('.', '') + received_str = match.group(2).replace(',', '').replace('.', '') + bytes_sent = int(sent_str) + bytes_received = int(received_str) + transferred_size = bytes_sent + bytes_received + self.logger.log( + f"Detected total bytes transferred from summary: {transferred_size} bytes") + break # Found it + except (ValueError, IndexError) as e: + self.logger.log( + f"Could not parse sent/received bytes from line: '{line}'. Error: {e}") + + if transferred_size == 0: + # Fallback for --stats format if the regex fails + bytes_sent = 0 + bytes_received = 0 + for line in output_lines: + if line.strip().startswith('Total bytes sent:'): + try: + size_str = line.split(':')[1].strip() + bytes_sent = int(size_str.replace(',', '').replace('.', '')) + except (ValueError, IndexError): + self.logger.log(f"Could not parse bytes sent from line: {line}") + elif line.strip().startswith('Total bytes received:'): + try: + size_str = line.split(':')[1].strip() + bytes_received = int(size_str.replace(',', '').replace('.', '')) + except (ValueError, IndexError): + self.logger.log(f"Could not parse bytes received from line: {line}") + + if bytes_sent > 0 or bytes_received > 0: + transferred_size = bytes_sent + bytes_received + self.logger.log( + f"Detected total bytes transferred from --stats: {transferred_size} bytes") + else: + self.logger.log( + "Could not determine transferred size from rsync output. Size will be 0.") except FileNotFoundError: self.logger.log( diff --git a/core/data_processing.py b/core/data_processing.py index 2c7d81f..fa4f167 100644 --- a/core/data_processing.py +++ b/core/data_processing.py @@ -202,87 +202,5 @@ class DataProcessing: app_logger.log(f"An unexpected error occurred during incremental size calculation: {e}") return 0 - def process_queue(self): - try: - message = self.app.queue.get_nowait() - - # Check for the new message format with status - calc_type, status = None, None - if len(message) == 5: - button_text, folder_size, mode_when_started, calc_type, status = message - elif len(message) == 3: - button_text, folder_size, mode_when_started = message - else: - return # Ignore malformed messages - - if mode_when_started != self.app.mode: - if calc_type == 'accurate_incremental': - self.app.actions._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 # Discard stale result - - # --- Update Main Canvas --- - current_folder_name = self.app.left_canvas_data.get('folder') - if current_folder_name == button_text: - if self.app.left_canvas_animation: - self.app.left_canvas_animation.stop() - self.app.left_canvas_animation.destroy() - self.app.left_canvas_animation = None - - size_in_gb = folder_size / (1024**3) - size_str = f"{size_in_gb:.2f} GB" if size_in_gb >= 1 else f"{folder_size / (1024*1024):.2f} MB" - - self.app.left_canvas_data['size'] = size_str - self.app.left_canvas_data['total_bytes'] = folder_size - self.app.left_canvas_data['calculating'] = False - self.app.drawing.redraw_left_canvas() - - self.app.source_size_bytes = folder_size - # --- Update Bottom Canvases --- - if self.app.mode == 'backup': - # Ensure button_text is a valid key in FOLDER_PATHS - if button_text in AppConfig.FOLDER_PATHS: - total_disk_size, _, _ = shutil.disk_usage(AppConfig.FOLDER_PATHS[button_text]) - if folder_size > total_disk_size: - self.app.source_larger_than_partition = True - else: - self.app.source_larger_than_partition = False - - percentage = (folder_size / total_disk_size) * 100 if total_disk_size > 0 else 0 - - self.app.source_size_canvas.delete("all") - fill_width = (self.app.source_size_canvas.winfo_width() / 100) * percentage - self.app.source_size_canvas.create_rectangle(0, 0, fill_width, self.app.source_size_canvas.winfo_height(), fill="#0078d7", outline="") - self.app.source_size_label.config(text=f"{folder_size / (1024**3):.2f} GB / {total_disk_size / (1024**3):.2f} GB") - - self.app.drawing.update_target_projection() - - # --- Enable Start Button Logic --- - if self.app.mode == 'backup' and self.app.destination_path: - self.app.start_pause_button.config(state="normal") - - # --- Handle Accurate Calculation Completion --- - if calc_type == 'accurate_incremental': - self.app.source_size_bytes = folder_size # Update the source size - self.app.drawing.update_target_projection() # Redraw the projection - - self.app.animated_icon.stop("DISABLE") - self.app.task_progress.stop() - self.app.task_progress.config(mode="determinate", value=0) - self.app.actions._set_ui_state(True) - self.app.genaue_berechnung_var.set(False) - self.app.accurate_calculation_running = False - self.app.start_pause_button.config(text=Msg.STR["start"]) - if status == 'success': - self.app.info_label.config(text=Msg.STR["accurate_size_success"], foreground="#0078d7") - self.app.current_file_label.config(text="") - else: - self.app.info_label.config(text=Msg.STR["accurate_size_failed"], foreground="#D32F2F") # Red for failed - self.app.current_file_label.config(text="") - - except Empty: - pass - finally: - self.app.after(100, self.process_queue) + # The queue processing logic has been moved to main_app.py + # to fix a race condition and ensure all queue messages are handled correctly. diff --git a/main_app.py b/main_app.py index f1c66ce..cddc21c 100644 --- a/main_app.py +++ b/main_app.py @@ -372,7 +372,7 @@ class MainApplication(tk.Tk): 'folder', 'Computer') self.after(100, self.actions.on_sidebar_button_click, restore_dest_folder) - self.data_processing.process_queue() + self._process_queue() def _setup_log_window(self): self.log_frame = ttk.Frame(self.content_frame) @@ -547,13 +547,91 @@ class MainApplication(tk.Tk): app_logger.log(Msg.STR["app_quit"]) self.destroy() - def _process_backup_queue(self): - """Processes messages from the backup thread queue to update the UI safely.""" + def _process_queue(self): + """ + Processes all messages from background threads to update the UI safely. + This is the single, consolidated queue processing loop for the entire application. + It processes messages in batches to avoid freezing the UI. + """ try: - while True: + for _ in range(100): # Process up to 100 messages at a time message = self.queue.get_nowait() - if isinstance(message, tuple) and len(message) == 2: + # --- Size Calculation Message Handling (from data_processing) --- + if isinstance(message, tuple) and len(message) in [3, 5]: + calc_type, status = None, None + if len(message) == 5: + button_text, folder_size, mode_when_started, calc_type, status = message + else: # len == 3 + button_text, folder_size, mode_when_started = message + + if mode_when_started != self.mode: + if calc_type == 'accurate_incremental': + self.actions._set_ui_state(True) + self.genaue_berechnung_var.set(False) + self.accurate_calculation_running = False + self.animated_icon.stop("DISABLE") + else: + current_folder_name = self.left_canvas_data.get('folder') + if current_folder_name == button_text: + if self.left_canvas_animation: + self.left_canvas_animation.stop() + self.left_canvas_animation.destroy() + self.left_canvas_animation = None + + size_in_gb = folder_size / (1024**3) + size_str = f"{size_in_gb:.2f} GB" if size_in_gb >= 1 else f"{folder_size / (1024*1024):.2f} MB" + + self.left_canvas_data['size'] = size_str + self.left_canvas_data['total_bytes'] = folder_size + self.left_canvas_data['calculating'] = False + self.drawing.redraw_left_canvas() + + self.source_size_bytes = folder_size + if self.mode == 'backup': + if button_text in AppConfig.FOLDER_PATHS: + total_disk_size, _, _ = shutil.disk_usage( + AppConfig.FOLDER_PATHS[button_text]) + if folder_size > total_disk_size: + self.source_larger_than_partition = True + else: + self.source_larger_than_partition = False + percentage = ( + folder_size / total_disk_size) * 100 if total_disk_size > 0 else 0 + self.source_size_canvas.delete("all") + fill_width = ( + self.source_size_canvas.winfo_width() / 100) * percentage + self.source_size_canvas.create_rectangle( + 0, 0, fill_width, self.source_size_canvas.winfo_height(), fill="#0078d7", outline="") + self.source_size_label.config( + text=f"{folder_size / (1024**3):.2f} GB / {total_disk_size / (1024**3):.2f} GB") + + self.drawing.update_target_projection() + + if self.mode == 'backup' and self.destination_path: + self.start_pause_button.config(state="normal") + + if calc_type == 'accurate_incremental': + self.source_size_bytes = folder_size + self.drawing.update_target_projection() + self.animated_icon.stop("DISABLE") + self.task_progress.stop() + self.task_progress.config(mode="determinate", value=0) + self.actions._set_ui_state(True) + self.genaue_berechnung_var.set(False) + self.accurate_calculation_running = False + self.start_pause_button.config(text=Msg.STR["start"]) + if status == 'success': + self.info_label.config( + text=Msg.STR["accurate_size_success"], foreground="#0078d7") + self.current_file_label.config(text="") + else: + self.info_label.config( + text=Msg.STR["accurate_size_failed"], foreground="#D32F2F") + self.current_file_label.config(text="") + + # --- Backup/Deletion Message Handling (from main_app) --- + elif isinstance(message, tuple) and len(message) == 2: message_type, value = message if message_type == 'progress': @@ -575,6 +653,7 @@ class MainApplication(tk.Tk): elif message_type == 'cancel_button_state': self.start_pause_button.config(state=value) elif message_type == 'deletion_complete': + self.actions._set_ui_state(True) self.backup_content_frame.hide_deletion_status() self.backup_content_frame.system_backups_frame._load_backup_content() self.backup_content_frame.user_backups_frame._load_backup_content() @@ -584,10 +663,10 @@ class MainApplication(tk.Tk): self.backup_is_running = False elif message_type == 'completion': status_info = value - status = 'error' # Default + status = 'error' if isinstance(status_info, dict): status = status_info.get('status', 'error') - elif status_info is None: # Fallback for older logic + elif status_info is None: status = 'success' if status == 'success': @@ -600,7 +679,6 @@ class MainApplication(tk.Tk): 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") @@ -625,21 +703,16 @@ class MainApplication(tk.Tk): self.actions._set_ui_state(True) self.backup_content_frame.system_backups_frame._load_backup_content() elif message_type == 'completion_accurate': - if value == 'success': - self.info_label.config( - text=Msg.STR["accurate_size_success"]) - else: - self.info_label.config( - text=Msg.STR["accurate_size_failed"]) - self.actions._set_ui_state(True) + # This is now handled by the len=5 case above + pass else: - self.queue.put(message) - break + app_logger.log(f"Unknown message in queue: {message}") except Empty: - pass - - self.after(100, self._process_backup_queue) + pass # The queue is empty, do nothing. + finally: + # Always schedule the next check. + self.after(100, self._process_queue) def quit(self): self.on_closing() diff --git a/pbp_app_config.py b/pbp_app_config.py index 32595a0..cf33837 100644 --- a/pbp_app_config.py +++ b/pbp_app_config.py @@ -275,6 +275,7 @@ class Msg: "add_file_button": _("File"), "system_excludes": _("System Excludes"), "manual_excludes": _("Manual Excludes"), + "manual_excludes_info": _("Here, manually add files or folders to be excluded from the backup. Each entry should be on a new line."), # Menus "file_menu": _("File"), diff --git a/pyimage_ui/actions.py b/pyimage_ui/actions.py index 96697cf..feb4469 100644 --- a/pyimage_ui/actions.py +++ b/pyimage_ui/actions.py @@ -624,8 +624,6 @@ class Actions: self.app.animated_icon.start() - self.app._process_backup_queue() - if self.app.mode == "backup": source_size_bytes = self.app.source_size_bytes if self.app.vollbackup_var.get():