diff --git a/backup_manager.py b/backup_manager.py index 3d92638..bb75cd2 100644 --- a/backup_manager.py +++ b/backup_manager.py @@ -416,7 +416,7 @@ class BackupManager: 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) + 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" @@ -471,7 +471,7 @@ class BackupManager: with open(info_file_path, 'r') as f: for line in f: if line.strip().lower().startswith("originalgröße:"): - size_match = re.search(r":\s*(.*)\s*((", line) + size_match = re.search(r":\s*(.*?)\s*\(", line) if size_match: backup_size = size_match.group(1).strip() else: diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..6ea8cc3 --- /dev/null +++ b/config_manager.py @@ -0,0 +1,83 @@ +import json +from pathlib import Path +from typing import Any, Dict +from json import JSONEncoder + + +class PathEncoder(JSONEncoder): + def default(self, obj): + if isinstance(obj, Path): + return str(obj) + return JSONEncoder.default(self, obj) + + +class ConfigManager: + """Manages loading and saving of application settings in a JSON file.""" + + def __init__(self, file_path: Path): + """ + Initializes the ConfigManager. + + Args: + file_path: The path to the configuration file. + """ + self.file_path = file_path + self.settings: Dict[str, Any] = {} + self.load() + + def load(self) -> None: + """Loads the settings from the JSON file.""" + if self.file_path.exists(): + try: + with open(self.file_path, 'r', encoding='utf-8') as f: + self.settings = json.load(f) + except (IOError, json.JSONDecodeError) as e: + print(f"Error loading config file {self.file_path}: {e}") + self.settings = {} + else: + self.settings = {} + + def save(self) -> None: + """Saves the current settings to the JSON file.""" + try: + # Ensure the parent directory exists + self.file_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.file_path, 'w', encoding='utf-8') as f: + json.dump(self.settings, f, indent=4, cls=PathEncoder) + except IOError as e: + print(f"Error saving config file {self.file_path}: {e}") + + def get_setting(self, key: str, default: Any = None) -> Any: + """ + Gets a setting value. + + Args: + key: The key of the setting. + default: The default value to return if the key is not found. + + Returns: + The value of the setting or the default value. + """ + return self.settings.get(key, default) + + def set_setting(self, key: str, value: Any) -> None: + """ + Sets a setting value and immediately saves it to the file. + + Args: + key: The key of the setting. + value: The value to set. + """ + self.settings[key] = value + self.save() + + def remove_setting(self, key: str) -> None: + """ + Removes a setting from the configuration. + + Args: + key: The key of the setting to remove. + """ + if key in self.settings: + del self.settings[key] + self.save() diff --git a/core/data_processing.py b/core/data_processing.py index 81da0c9..d1ae75d 100644 --- a/core/data_processing.py +++ b/core/data_processing.py @@ -3,7 +3,9 @@ import os import fnmatch import shutil import re -from pbp_app_config import AppConfig +import subprocess +from queue import Empty +from pbp_app_config import AppConfig, Msg from shared_libs.logger import app_logger @@ -101,19 +103,110 @@ class DataProcessing: if not stop_event.is_set(): self.app.queue.put((button_text, total_size, mode)) + def get_incremental_backup_size(self, source_path: str, dest_path: str, is_system: bool, exclude_files: list = None) -> int: + """ + Calculates the approximate size of an incremental backup using rsync's dry-run feature. + This is much faster than walking the entire file tree. + """ + app_logger.log(f"Calculating incremental backup size for source: {source_path}") + + parent_dest = os.path.dirname(dest_path) + if not os.path.exists(parent_dest): + # If the parent destination doesn't exist, there are no previous backups to link to. + # In this case, the incremental size is the full size of the source. + # We can use the existing full-size calculation method. + # This is a simplified approach for the estimation. + # A more accurate one would run rsync without --link-dest. + app_logger.log("Destination parent does not exist, cannot calculate incremental size. Returning 0.") + return 0 + + # Find the latest backup to link against + try: + backups = sorted([d for d in os.listdir(parent_dest) if os.path.isdir(os.path.join(parent_dest, d))], reverse=True) + if not backups: + app_logger.log("No previous backups found. Incremental size is full size.") + return 0 # Or trigger a full size calculation + latest_backup_path = os.path.join(parent_dest, backups[0]) + except FileNotFoundError: + app_logger.log("Could not list backups, assuming no prior backups exist.") + return 0 + + + command = [] + if is_system: + command.extend(['pkexec', 'rsync', '-aAXHvn', '--stats']) + else: + command.extend(['rsync', '-avn', '--stats']) + + command.append(f"--link-dest={latest_backup_path}") + + if exclude_files: + for exclude_file in exclude_files: + command.append(f"--exclude-from={exclude_file}") + + # The destination for a dry run can be a dummy path, but it must exist. + # Let's use a temporary directory. + dummy_dest = os.path.join(parent_dest, "dry_run_dest") + os.makedirs(dummy_dest, exist_ok=True) + + command.extend([source_path, dummy_dest]) + + app_logger.log(f"Executing rsync dry-run command: {' '.join(command)}") + + try: + result = subprocess.run(command, capture_output=True, text=True, check=False) + + # Clean up the dummy directory + shutil.rmtree(dummy_dest) + + if result.returncode != 0: + app_logger.log(f"Rsync dry-run failed with code {result.returncode}: {result.stderr}") + return 0 + + output = result.stdout + "\n" + result.stderr + # The regex now accepts dots as thousands separators (e.g., 1.234.567). + match = re.search(r"Total transferred file size: ([\d,.]+) bytes", output) + if match: + # Remove both dots and commas before converting to an integer. + size_str = match.group(1).replace(',', '').replace('.', '') + size_bytes = int(size_str) + app_logger.log(f"Estimated incremental backup size: {size_bytes} bytes") + return size_bytes + else: + app_logger.log("Could not find 'Total transferred file size' in rsync output.") + # Log the output just in case something changes in the future + app_logger.log(f"Full rsync output for debugging:\n{output}") + return 0 + + except FileNotFoundError: + app_logger.log("Error: 'rsync' or 'pkexec' command not found.") + return 0 + except Exception as e: + 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() - button_text, folder_size, mode_when_started = message + + # 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: - return # Discard stale result from a different mode - - if self.app.mode == 'restore': - self.app.restore_destination_folder_size_bytes = folder_size - else: # backup mode - self.app.source_size_bytes = folder_size + 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: @@ -121,43 +214,54 @@ class DataProcessing: self.app.left_canvas_animation.destroy() self.app.left_canvas_animation = None - if not self.app.right_canvas_data.get('calculating', False): - self.app.start_pause_button.config(state="normal") - size_in_gb = folder_size / (1024**3) - if size_in_gb >= 1: - size_str = f"{size_in_gb:.2f} GB" - else: - size_str = f"{folder_size / (1024*1024):.2f} MB" + 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': - 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 + # 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 - if total_disk_size > 0: - percentage = (folder_size / total_disk_size) * 100 - else: - percentage = 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") + 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() - except Exception as e: + # --- 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) diff --git a/main_app.py b/main_app.py index 09c72f4..64a4535 100644 --- a/main_app.py +++ b/main_app.py @@ -86,6 +86,7 @@ class MainApplication(tk.Tk): self.calculation_thread = None self.calculation_stop_event = None self.source_larger_than_partition = False + self.accurate_calculation_running = False self.is_first_backup = False self.left_canvas_animation = None @@ -401,6 +402,15 @@ class MainApplication(tk.Tk): self.backup_content_frame.grid_remove() def _setup_task_bar(self): + # Define all boolean vars at the top to ensure they exist before use. + self.vollbackup_var = tk.BooleanVar() + self.inkrementell_var = tk.BooleanVar() + self.genaue_berechnung_var = tk.BooleanVar() + self.testlauf_var = tk.BooleanVar() + self.compressed_var = tk.BooleanVar() + self.encrypted_var = tk.BooleanVar() + self.bypass_security_var = tk.BooleanVar() + self.info_checkbox_frame = ttk.Frame(self.content_frame, padding=10) self.info_checkbox_frame.grid(row=3, column=0, sticky="ew") @@ -421,20 +431,25 @@ class MainApplication(tk.Tk): 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) --- + accurate_size_frame = ttk.Frame(self.time_info_frame) + 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) + 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.pack(side=tk.LEFT) + checkbox_frame = ttk.Frame(self.info_checkbox_frame) checkbox_frame.pack(fill=tk.X, pady=5) - self.vollbackup_var = tk.BooleanVar() - self.inkrementell_var = tk.BooleanVar() - self.testlauf_var = tk.BooleanVar() - self.compressed_var = tk.BooleanVar() - self.encrypted_var = tk.BooleanVar() - self.bypass_security_var = tk.BooleanVar() self.full_backup_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["full_backup"], - variable=self.vollbackup_var, command=lambda: enforce_backup_type_exclusivity(self.vollbackup_var, self.inkrementell_var, self.vollbackup_var.get())) + variable=self.vollbackup_var, command=lambda: self.actions.handle_backup_type_change('voll')) self.full_backup_cb.pack(side=tk.LEFT, padx=5) self.incremental_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["incremental"], - variable=self.inkrementell_var, command=lambda: enforce_backup_type_exclusivity(self.inkrementell_var, self.vollbackup_var, self.inkrementell_var.get())) + variable=self.inkrementell_var, command=lambda: self.actions.handle_backup_type_change('inkrementell')) self.incremental_cb.pack(side=tk.LEFT, padx=5) self.compressed_cb = ttk.Checkbutton(checkbox_frame, text=Msg.STR["compressed"], @@ -572,6 +587,12 @@ class MainApplication(tk.Tk): self.backup_is_running = False self.actions._set_ui_state(True) # Re-enable UI + elif message_type == 'completion_accurate': + if value == 'success': + self.current_file_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 else: # This message is not for us (likely for DataProcessing), put it back and yield. self.queue.put(message) diff --git a/pbp_app_config.py b/pbp_app_config.py index fb5977a..5afe3cc 100644 --- a/pbp_app_config.py +++ b/pbp_app_config.py @@ -256,6 +256,12 @@ 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."), + "accurate_size_cb_label": _("Accurate inkrem. size"), + "accurate_size_info_label": _("(Calculation may take longer)"), + "accurate_size_success": _("Accurate size calculated successfully."), + "accurate_size_failed": _("Failed to calculate size. See log for details."), + "please_wait": _("Please wait, calculating..."), + "accurate_calc_cancelled": _("Calculate size cancelled."), # Menus "file_menu": _("File"), diff --git a/pyimage_ui/actions.py b/pyimage_ui/actions.py index ab9ae64..644ea3d 100644 --- a/pyimage_ui/actions.py +++ b/pyimage_ui/actions.py @@ -47,20 +47,123 @@ class Actions: if full_backup_exists: # Case 1: A full backup exists. Allow user to choose, default to incremental. - self.app.vollbackup_var.set(False) - self.app.inkrementell_var.set(True) + 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 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 + + 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 + self.app.inkrementell_var.set(False) + elif changed_var_name == 'inkrementell': + 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 + if self.app.vollbackup_var.get(): + self.app.accurate_size_cb.config(state="disabled") + self.app.genaue_berechnung_var.set(False) + else: + # Only enable if it's an incremental backup of the system + if self.app.left_canvas_data.get('folder') == "Computer": + self.app.accurate_size_cb.config(state="normal") + else: + self.app.accurate_size_cb.config(state="disabled") + + 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(): + # Box was unchecked by user, but this shouldn't happen if disabled. + # Or it was unchecked automatically after a run. Do nothing. + return + + # If a normal calculation is running, stop it. + if self.app.calculation_thread and self.app.calculation_thread.is_alive(): + self.app.calculation_stop_event.set() + app_logger.log("Stopping previous size calculation.") + + # --- 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 + 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.task_progress.config(mode="indeterminate") + self.app.task_progress.start() + self.app.left_canvas_data.update({ + 'size': Msg.STR["calculating_size"], + 'calculating': True, + }) + self.app.drawing.start_backup_calculation_display() + self.app.animated_icon.start() + + # Get folder path from the current canvas data + folder_path = self.app.left_canvas_data.get('path_display') + 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 + self.app.accurate_calculation_running = False + self.app.animated_icon.stop("DISABLE") + return + + def threaded_incremental_calc(): + 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) + 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") + + size = self.app.data_processing.get_incremental_backup_size( + source_path=folder_path, + dest_path=dummy_dest_for_calc, + is_system=True, + exclude_files=exclude_file_paths + ) + # If rsync fails, size is 0, so status will be failure. + status = 'success' if size > 0 else 'failure' + except Exception as e: + app_logger.log(f"Error during threaded_incremental_calc: {e}") + status = 'failure' + 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)) + + 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: - app_logger.log("Action blocked: Backup is in progress.") + if self.app.backup_is_running or self.app.accurate_calculation_running: + app_logger.log("Action blocked: Backup or accurate calculation is in progress.") return self.app.drawing.reset_projection_canvases() @@ -112,7 +215,15 @@ class Actions: else: # restore extra_info = Msg.STR["user_restore_info"] - # Unified logic for starting a calculation on the left canvas + # CORRECTED LOGIC ORDER: + # 1. Update backup type checkboxes BEFORE starting the calculation. + if self.app.mode == 'backup': + self._update_backup_type_controls() + else: # restore mode + self.app.config_manager.set_setting( + "restore_destination_path", folder_path) + + # 2. Unified logic for starting a calculation on the left canvas. self._start_left_canvas_calculation( button_text, str(folder_path), icon_name, extra_info) @@ -159,9 +270,10 @@ class Actions: self.app.calculation_stop_event = threading.Event() - # Decide which calculation method to use based on the source + # 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": - # For system backup, use exclusions + 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, @@ -329,7 +441,7 @@ class Actions: except ValueError: return 0 - def _set_ui_state(self, enable: bool): + def _set_ui_state(self, enable: bool, keep_cancel_enabled: bool = False): # Sidebar Buttons for text, data in self.app.buttons_map.items(): # Find the actual button widget in the sidebar_buttons_frame @@ -352,13 +464,7 @@ class Actions: # Top Navigation Buttons for i, button in enumerate(self.app.nav_buttons): - # Keep "Backup" and "Log" always enabled - if ( - self.app.nav_buttons_defs[i][0] == Msg.STR["backup_menu"] or - self.app.nav_buttons_defs[i][0] == Msg.STR["log"]): - button.config(state="normal") - else: - button.config(state="normal" if enable else "disabled") + button.config(state="normal" if enable else "disabled") # Right Canvas (Destination/Restore Source) if enable: @@ -380,6 +486,7 @@ class Actions: checkboxes = [ self.app.full_backup_cb, self.app.incremental_cb, + self.app.accurate_size_cb, self.app.compressed_cb, self.app.encrypted_cb, self.app.test_run_cb, @@ -387,8 +494,33 @@ 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") def toggle_start_cancel(self): + # If an accurate calculation is running, cancel it. + if self.app.accurate_calculation_running: + 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: + self.app.left_canvas_animation.stop() + self.app.left_canvas_data['calculating'] = False + self.app.drawing.redraw_left_canvas() + + # 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.start_pause_button.config(text=Msg.STR["start"]) + self._set_ui_state(True) + return + # If a backup is already running, we must be cancelling. if self.app.backup_is_running: self.app.animated_icon.stop("DISABLE") @@ -499,9 +631,7 @@ class Actions: # Store the path for potential deletion self.app.current_backup_path = final_dest - # Get source size from canvas data and parse it - size_display_str = self.app.left_canvas_data.get('size', '0 B') - source_size_bytes = self._parse_size_string_to_bytes(size_display_str) + source_size_bytes = self.app.left_canvas_data.get('total_bytes', 0) exclude_file_paths = [] if AppConfig.GENERATED_EXCLUDE_LIST_PATH.exists():