├── .gitignore ├── README.md ├── __init__.py ├── core.py └── preferences.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blender Backup Manager 2 | 3 | This addon is in development, i recommend making a manual backup before start to use this addon. 4 | 5 | This addon lets you backup and restore your Blender settings to a specified location so it can easy be shared via cloud folders or networks. 6 | 7 | 8 | ## Simple 9 | In the simple mode the addon is backing-up or restoring the version of the active blender application. 10 | You can see the age and the size of your last backup, by default cache is excluded from the backup due to generated files which take a lot of space (it can be enabled in the advanced options) 11 | ![image](https://user-images.githubusercontent.com/1472884/141808655-50ddced4-8d3a-480b-8969-dd38787cdc1b.png) 12 | 13 | ## Advanced 14 | In advanced mode you can choose of any existing versions you had running on your system. 15 | This also provides the option to back up all existing versions from your system, a custom version, delete a backup and choose which elements you want to backup. 16 | ![image](https://user-images.githubusercontent.com/1472884/141809165-1f0a9fd6-aa84-4703-ae4c-300fa28f7d44.png) 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | 19 | # Standard library imports 20 | import importlib 21 | 22 | # Third-party imports (Blender API) 23 | import bpy 24 | from bpy.types import Context 25 | from datetime import datetime # Added for debug timestamps 26 | 27 | # --- Module Reloading --- 28 | # These will be populated by reload_addon_modules or initial import 29 | preferences = None 30 | core = None 31 | 32 | def _reload_addon_submodules(): 33 | """Force reload of addon submodules for development.""" 34 | global preferences, core 35 | # print("Backup Manager: Reloading submodules...") # Optional debug print 36 | 37 | # Import or re-import the modules using their full path from the package 38 | # This ensures that 'preferences' and 'core' are module objects. 39 | _preferences_module = importlib.import_module(".preferences", __package__) 40 | _core_module = importlib.import_module(".core", __package__) 41 | 42 | importlib.reload(_preferences_module) 43 | importlib.reload(_core_module) 44 | 45 | preferences = _preferences_module 46 | core = _core_module 47 | # print("Backup Manager: Submodules reloaded.") # Optional debug print 48 | 49 | # Check if running in Blender's UI and not in background mode before reloading submodules. 50 | if "bpy" in locals() and getattr(bpy.app, 'background_mode', False) is False: 51 | _reload_addon_submodules() 52 | else: 53 | from . import preferences as initial_preferences 54 | from . import core as initial_core 55 | preferences = initial_preferences 56 | core = initial_core 57 | # --- End Module Reloading --- 58 | 59 | 60 | bl_info = { 61 | "name": "Backup Manager", 62 | "description": "Backup and Restore your Blender configuration files", 63 | "author": "Daniel Grauer", 64 | "version": (1, 3, 1), # Consider incrementing version after changes 65 | "blender": (3, 0, 0), 66 | "location": "Preferences", 67 | "category": "!System", 68 | "wiki_url": "https://github.com/kromar/blender_BackupManager", 69 | "tracker_url": "https://github.com/kromar/blender_BackupManager/issues/new", 70 | } 71 | 72 | # Module-level list to keep track of classes registered by this addon instance. 73 | _registered_classes = [] 74 | 75 | def prefs_func(): # Renamed from prefs to avoid conflict with 'preferences' module 76 | """ 77 | Directly retrieves the addon's preferences. 78 | Assumes bpy.context and addon preferences are always accessible. 79 | """ 80 | user_preferences = bpy.context.preferences 81 | return user_preferences.addons[__package__].preferences 82 | 83 | 84 | def menus_draw_fn(self, context: Context) -> None: 85 | """Callback to add main menu entry.""" 86 | layout = self.layout 87 | # --- Debug flag retrieval (early, for use in this function) --- 88 | _local_debug_active = False 89 | _addon_prefs_for_debug_check = None # Renamed to avoid conflict if it remains None 90 | try: 91 | # Try to get prefs once for debug flag and potential reuse 92 | _addon_prefs_for_debug_check = prefs_func() 93 | if _addon_prefs_for_debug_check and hasattr(_addon_prefs_for_debug_check, 'debug'): 94 | _local_debug_active = _addon_prefs_for_debug_check.debug 95 | except Exception: 96 | # If prefs_func() fails here, _local_debug_active remains False. 97 | # This is acceptable as we can't log debug messages without prefs. 98 | pass 99 | 100 | if _local_debug_active: 101 | print(f"DEBUG __init__.menus_draw_fn: Entered. Current time: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}") 102 | 103 | # Ensure the core module and the operator class are loaded 104 | if not core or not hasattr(core, 'OT_BackupManagerWindow'): 105 | layout.label(text="Backup Manager (Operator Error)", icon='ERROR') 106 | if _local_debug_active: print("DEBUG __init__.menus_draw_fn: core or OT_BackupManagerWindow missing.") 107 | return 108 | 109 | op_idname = core.OT_BackupManagerWindow.bl_idname 110 | # Check if the operator is actually registered and available in bpy.ops.bm 111 | # op_idname.split('.')[-1] would be 'open_backup_manager_window' 112 | if not hasattr(bpy.ops.bm, op_idname.split('.')[-1]): 113 | layout.label(text="Backup Manager: Operator not found in bpy.ops.bm", icon='ERROR') 114 | if _local_debug_active: print(f"DEBUG __init__.menus_draw_fn: Operator {op_idname} missing in bpy.ops.bm.") 115 | return 116 | 117 | # Try to get preferences to check operation status 118 | # Reuse _addon_prefs_for_debug_check if it was successfully retrieved 119 | addon_prefs = _addon_prefs_for_debug_check 120 | if addon_prefs is None: # If it failed to fetch earlier or was None from the start 121 | try: 122 | addon_prefs = prefs_func() 123 | except Exception as e_prefs_get: 124 | if _local_debug_active: 125 | print(f"ERROR __init__.menus_draw_fn: Exception getting addon_prefs: {e_prefs_get}") 126 | # Fallback: draw default button if prefs are inaccessible 127 | layout.operator(op_idname, text="Backup Manager", icon='DISK_DRIVE') 128 | if _local_debug_active: print(f"DEBUG __init__.menus_draw_fn: Drew default operator due to prefs error. Exiting. Current time: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}") 129 | return 130 | 131 | button_text = "Backup Manager" 132 | button_icon = 'DISK_DRIVE' # Default icon 133 | 134 | if _local_debug_active: 135 | sop_value = 'N/A (prefs None or attr missing)' 136 | if addon_prefs and hasattr(addon_prefs, 'show_operation_progress'): 137 | sop_value = addon_prefs.show_operation_progress 138 | print(f"DEBUG __init__.menus_draw_fn: addon_prefs {'IS valid' if addon_prefs else 'IS NONE'}. show_operation_progress = {sop_value}") 139 | 140 | if addon_prefs and hasattr(addon_prefs, 'show_operation_progress') and addon_prefs.show_operation_progress: 141 | if _local_debug_active: 142 | print(f"DEBUG __init__.menus_draw_fn: Condition MET. Setting text/icon to 'Running...'.") 143 | button_text = "Backup Manager (Running...)" 144 | button_icon = 'COLORSET_09_VEC' # Icon indicating activity/warning 145 | elif _local_debug_active: # Only print if debug is on and condition was false 146 | print(f"DEBUG __init__.menus_draw_fn: Condition NOT MET for 'Running...' state.") 147 | 148 | try: 149 | layout.operator(op_idname, text=button_text, icon=button_icon) 150 | if _local_debug_active: 151 | print(f"DEBUG __init__.menus_draw_fn: Operator drawn with text='{button_text}', icon='{button_icon}'.") 152 | layout.separator() # Add this line to draw a separator after your operator 153 | except Exception as e: 154 | # Log the error if layout.operator fails, to help diagnose 155 | print(f"ERROR: Backup Manager: menus_draw_fn failed to draw operator '{op_idname}'. Exception: {type(e).__name__}: {e}") 156 | # If drawing the operator fails, we log the error but do not display a fallback UI error label in the menu. 157 | # The menu item for the addon will simply be absent if this occurs. 158 | pass 159 | 160 | if _local_debug_active: 161 | print(f"DEBUG __init__.menus_draw_fn: Exiting. Current time: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}") 162 | 163 | # Register and unregister functions 164 | def register(): 165 | global _registered_classes 166 | _registered_classes.clear() # Clear from any previous registration attempt in this session 167 | 168 | # Ensure submodules are loaded (important if register is called standalone after an error) 169 | if not core or not preferences: 170 | _reload_addon_submodules() # Attempt to load/reload them 171 | if not core or not preferences: 172 | print("ERROR: Backup Manager: Core modules (core, preferences) could not be loaded. Registration cannot proceed.") 173 | return 174 | 175 | # Define the classes to register, AddonPreferences first 176 | classes_to_register_dynamically = ( 177 | preferences.BM_Preferences, 178 | preferences.OT_OpenPathInExplorer, 179 | core.OT_BackupManager, 180 | core.OT_AbortOperation, 181 | core.OT_ShowFinalReport, 182 | core.OT_QuitBlenderNoSave, 183 | core.OT_CloseReportDialog, 184 | core.OT_BackupManagerWindow, 185 | ) 186 | 187 | _debug_active = False # Default to False for safety 188 | try: 189 | try: 190 | addon_prefs_instance = prefs_func() 191 | except KeyError: 192 | addon_prefs_instance = None 193 | print("WARNING: prefs_func() failed. Addon might be unregistered or context unavailable.") 194 | if addon_prefs_instance and hasattr(addon_prefs_instance, 'debug'): 195 | _debug_active = addon_prefs_instance.debug 196 | except Exception as e_prefs: 197 | # This might happen if prefs are not yet available or __package__ is not set during a very early call 198 | print(f"WARNING: Backup Manager register(): Could not access preferences for debug flag: {e_prefs}") 199 | 200 | if _debug_active: print("DEBUG: Backup Manager register() CALLED") 201 | 202 | for cls_to_reg in classes_to_register_dynamically: 203 | try: 204 | bpy.utils.register_class(cls_to_reg) 205 | _registered_classes.append(cls_to_reg) # Add to our list *after* successful registration 206 | if _debug_active: print(f"DEBUG: register(): Successfully registered {cls_to_reg.__name__}") 207 | except ValueError as e: 208 | if "already registered" in str(e).lower(): 209 | if _debug_active: print(f"INFO: Class {cls_to_reg.__name__} reported as already registered. Attempting to unregister and re-register.") 210 | try: 211 | bpy.utils.unregister_class(cls_to_reg) # Try to unregister it first 212 | bpy.utils.register_class(cls_to_reg) # Then re-register 213 | _registered_classes.append(cls_to_reg) # Assume success if no exception 214 | if _debug_active: print(f"DEBUG: register(): Re-registered {cls_to_reg.__name__} after 'already registered' error.") 215 | except Exception as e_rereg: 216 | print(f"ERROR: Backup Manager: Failed to re-register class {cls_to_reg.__name__} after 'already registered' error: {e_rereg}") 217 | else: # Other ValueError 218 | print(f"ERROR: Backup Manager: Failed to register class {cls_to_reg.__name__} (ValueError): {e}") 219 | except Exception as e: # Other exceptions 220 | print(f"ERROR: Backup Manager: Failed to register class {cls_to_reg.__name__} (General Exception): {e}") 221 | 222 | # Reset the initial scan flag on registration 223 | if hasattr(preferences, 'BM_Preferences'): 224 | preferences.BM_Preferences._initial_scan_done = False # Ensure this refers to the actual flag in BM_Preferences 225 | 226 | # Explicitly reset transient preference properties to their defaults. 227 | # This ensures that even if old values were somehow loaded from userpref.blend 228 | # (e.g., from before SKIP_SAVE was added or due to property definition changes), 229 | # they are reset to a clean state for the new session. 230 | try: 231 | prefs_instance = bpy.context.preferences.addons[__name__].preferences 232 | prefs_instance.show_operation_progress = False # Default 233 | prefs_instance.operation_progress_value = 0.0 # Default (property is 0-100 factor) 234 | prefs_instance.operation_progress_message = "Waiting..." # Default 235 | prefs_instance.abort_operation_requested = False # Default 236 | 237 | if prefs_instance.debug: 238 | print(f"DEBUG: {__name__} registered. Transient preference properties explicitly reset to defaults.") 239 | except Exception as e: 240 | print(f"ERROR: {__name__}: Could not reset transient preferences during registration: {e}") 241 | 242 | try: 243 | bpy.types.TOPBAR_MT_file.prepend(menus_draw_fn) 244 | except Exception as e: # Catch error if prepend fails (e.g. during headless run) 245 | if _debug_active: print(f"DEBUG: register(): Could not prepend menu_draw_fn to TOPBAR_MT_file: {e}") 246 | if _debug_active: print("DEBUG: Backup Manager register() FINISHED.") 247 | 248 | 249 | def unregister(): 250 | """ 251 | Unregisters all classes and removes menu entries added by the Backup Manager addon. 252 | Ensures proper cleanup when the addon is disabled or uninstalled. 253 | """ 254 | global _registered_classes 255 | _debug_active = False # Default to False for safety 256 | try: 257 | addon_prefs_instance = prefs_func() 258 | if addon_prefs_instance and hasattr(addon_prefs_instance, 'debug'): 259 | _debug_active = addon_prefs_instance.debug 260 | except Exception as e_prefs: 261 | # Similar to register(), prefs might be gone during shutdown 262 | if _debug_active: print(f"DEBUG: unregister(): Could not access preferences for debug flag: {e_prefs}") 263 | 264 | try: 265 | bpy.types.TOPBAR_MT_file.remove(menus_draw_fn) 266 | except Exception as e: # Can error if not found 267 | if _debug_active: print(f"DEBUG: unregister(): Error removing menu_draw_fn (may have already been removed): {e}") 268 | 269 | # Unregister classes that were successfully registered by this addon instance 270 | if _registered_classes: # Check if the list is not empty 271 | for cls_to_unreg in reversed(_registered_classes): 272 | try: 273 | bpy.utils.unregister_class(cls_to_unreg) 274 | if _debug_active: print(f"DEBUG: unregister(): Successfully unregistered {cls_to_unreg.__name__}") 275 | except Exception as e: 276 | print(f"ERROR: Backup Manager: Failed to unregister class {cls_to_unreg.__name__}: {e}") 277 | # Clear the list after unregistering all classes 278 | _registered_classes.clear() 279 | -------------------------------------------------------------------------------- /core.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | 19 | 20 | import bpy, subprocess, sys, os # Added subprocess, sys. os was already effectively imported via other uses. 21 | import os 22 | import shutil 23 | import fnmatch # For pattern matching in ignore list 24 | import re as regular_expression 25 | from datetime import datetime # Added for debug timestamps 26 | from bpy.types import Operator 27 | from bpy.props import StringProperty, EnumProperty 28 | from . import preferences 29 | from .preferences import BM_Preferences 30 | 31 | 32 | def prefs(): 33 | """ 34 | Directly retrieves the addon's preferences. 35 | Assumes bpy.context and addon preferences are always accessible. 36 | """ 37 | user_preferences = bpy.context.preferences 38 | return user_preferences.addons[__package__].preferences 39 | 40 | def find_versions(filepath): 41 | version_list = [] 42 | _start_time_fv = None 43 | if prefs().debug: 44 | _start_time_fv = datetime.now() 45 | print(f"DEBUG: find_versions START for path: {filepath}") 46 | 47 | if not filepath or not os.path.isdir(filepath): 48 | if prefs().debug: 49 | print(f"DEBUG: find_versions: filepath invalid or not a directory: {filepath}") 50 | return version_list 51 | 52 | try: 53 | _listdir_start_time_fv = None 54 | if prefs().debug: 55 | _listdir_start_time_fv = datetime.now() 56 | print(f"DEBUG: find_versions CALLING os.listdir for path: {filepath}") 57 | for file_or_dir_name in os.listdir(filepath): 58 | path = os.path.join(filepath, file_or_dir_name) 59 | if os.path.isdir(path): 60 | version_list.append((file_or_dir_name, file_or_dir_name, '')) 61 | if prefs().debug: 62 | _listdir_end_time_fv = datetime.now() 63 | print(f"DEBUG: (took: {(_listdir_end_time_fv - _listdir_start_time_fv).total_seconds():.6f}s) find_versions FINISHED os.listdir for path: {filepath}") 64 | except OSError as e: # Catch specific OS errors like PermissionError 65 | if prefs().debug: 66 | print(f"DEBUG: find_versions: Error accessing filepath {filepath}: {e}") 67 | 68 | if prefs().debug and _start_time_fv: 69 | # print("\nVersion List: ", version_list) 70 | _end_time_fv = datetime.now() 71 | # Consider summarizing 'version_list' if it can be very long. 72 | # For now, printing the full list for detailed debugging. 73 | print(f"DEBUG: (took: {(_end_time_fv - _start_time_fv).total_seconds():.6f}s) find_versions END for path: '{filepath}', found {len(version_list)} versions. List: {version_list}") 74 | 75 | return version_list 76 | 77 | 78 | class OT_AbortOperation(Operator): 79 | """Operator to signal cancellation of the ongoing backup/restore operation.""" 80 | bl_idname = "bm.abort_operation" 81 | bl_label = "Abort Backup/Restore" 82 | bl_description = "Requests cancellation of the current operation" 83 | 84 | def execute(self, context): 85 | prefs().abort_operation_requested = True 86 | # The modal operator will pick this up and handle the actual cancellation. 87 | if prefs().debug: 88 | print("DEBUG: OT_AbortOperation executed, abort_operation_requested set to True.") 89 | return {'FINISHED'} 90 | 91 | class OT_QuitBlenderNoSave(bpy.types.Operator): 92 | """Quits Blender without saving current user preferences.""" 93 | bl_idname = "bm.quit_blender_no_save" 94 | bl_label = "Quit & Restart Blender" 95 | bl_description = "Attempts to start a new Blender instance, then quits the current one. If 'Save on Quit' is enabled in Blender's preferences, you will be warned." 96 | 97 | @classmethod 98 | def poll(cls, context): 99 | return True # Always allow attempting to quit 100 | 101 | def invoke(self, context, event): 102 | # It's good practice to get addon_prefs once, especially if debug is checked multiple times. 103 | # However, ensure prefs() doesn't fail if context is minimal during early invoke. 104 | addon_prefs_instance = None 105 | try: 106 | addon_prefs_instance = prefs() 107 | except Exception as e: 108 | print(f"DEBUG: OT_QuitBlenderNoSave.invoke: Could not retrieve addon preferences for debug logging: {e}") 109 | 110 | prefs_main = context.preferences # Use bpy.context.preferences for global auto-save setting 111 | 112 | if addon_prefs_instance and addon_prefs_instance.debug: 113 | print(f"DEBUG: OT_QuitBlenderNoSave.invoke(): Context preferences type: {type(context.preferences)}, " 114 | f"Has 'use_preferences_save': {hasattr(context.preferences, 'use_preferences_save')}") 115 | 116 | # The original logic, now with the debug prints above it. 117 | # This line will still raise an AttributeError if 'use_preferences_save' is missing. 118 | # The debug output should help understand why. 119 | if prefs_main and hasattr(prefs_main, 'use_preferences_save') and prefs_main.use_preferences_save: 120 | # 'Save on Quit' is ON. We need to warn the user. 121 | return context.window_manager.invoke_confirm(self, event) 122 | else: 123 | # 'Save on Quit' is OFF, or attribute is missing (hasattr was False), or prefs_view is None. 124 | # If attribute is missing, we proceed as if it's OFF to avoid the dialog, 125 | # but the execute method will log this uncertainty. 126 | return self.execute(context) 127 | 128 | def execute(self, context): 129 | prefs_main = context.preferences # Use bpy.context.preferences 130 | addon_prefs = prefs() # Get addon preferences for debug 131 | 132 | if addon_prefs.debug: 133 | print(f"DEBUG: OT_QuitBlenderNoSave.execute():") 134 | print(f" context: {context}") 135 | print(f" context.preferences: {context.preferences}") 136 | print(f" type(prefs_main): {type(prefs_main)}") 137 | if prefs_main: 138 | try: 139 | print(f" dir(prefs_main): {dir(prefs_main)}") 140 | except Exception as e_dir: 141 | print(f" Error getting dir(prefs_main): {e_dir}") 142 | print(f" Has 'use_preferences_save' attribute?: {hasattr(prefs_main, 'use_preferences_save')}") 143 | 144 | # This is the line from the traceback. 145 | # We check hasattr again to be safe and for clearer logging. 146 | blender_will_save_on_quit = False # Default assumption 147 | if prefs_main and hasattr(prefs_main, 'use_preferences_save'): 148 | blender_will_save_on_quit = prefs_main.use_preferences_save 149 | elif prefs_main: # prefs_main exists but hasattr was False 150 | if addon_prefs.debug: 151 | print("WARNING: OT_QuitBlenderNoSave.execute: 'use_preferences_save' attribute missing on Preferences object. Assuming Blender will save preferences for safety.") 152 | blender_will_save_on_quit = True # Assume worst-case for logging if attribute is missing 153 | else: # prefs_view is None 154 | if addon_prefs.debug: 155 | print("WARNING: OT_QuitBlenderNoSave.execute: prefs_view is None. Assuming Blender will save preferences for safety.") 156 | blender_will_save_on_quit = True 157 | 158 | # --- Attempt to launch new Blender instance --- 159 | blender_exe = bpy.app.binary_path 160 | new_instance_launched_successfully = False 161 | if blender_exe and blender_exe is not None and os.path.exists(blender_exe): # Check if path is valid and not None 162 | try: 163 | if addon_prefs.debug: 164 | print(f"DEBUG: OT_QuitBlenderNoSave: Attempting to launch new Blender instance from: {blender_exe}") 165 | 166 | args = [blender_exe] 167 | kwargs = {} 168 | 169 | if sys.platform == "win32": 170 | DETACHED_PROCESS = 0x00000008 # subprocess.DETACHED_PROCESS 171 | kwargs['creationflags'] = DETACHED_PROCESS 172 | elif sys.platform == "darwin": # macOS 173 | pass # No special flags usually needed 174 | elif sys.platform.startswith("linux"): # Linux and other POSIX 175 | kwargs['start_new_session'] = True 176 | 177 | try: 178 | subprocess.Popen(args, **kwargs) 179 | except FileNotFoundError as e: 180 | if addon_prefs.debug: 181 | print(f"ERROR: OT_QuitBlenderNoSave: Blender executable not found: {e}") 182 | import traceback 183 | traceback.print_exc() 184 | except PermissionError as e: 185 | if addon_prefs.debug: 186 | print(f"ERROR: OT_QuitBlenderNoSave: Permission denied when launching Blender: {e}") 187 | import traceback 188 | traceback.print_exc() 189 | except Exception as e: 190 | if addon_prefs.debug: 191 | print(f"ERROR: OT_QuitBlenderNoSave: Unexpected error occurred: {e}") 192 | import traceback 193 | traceback.print_exc() 194 | 195 | new_instance_launched_successfully = True 196 | if addon_prefs.debug: 197 | print(f"DEBUG: OT_QuitBlenderNoSave: New Blender instance launch command issued.") 198 | if sys.platform == "win32" and 'creationflags' in kwargs: 199 | print(f"DEBUG: OT_QuitBlenderNoSave: Using creationflags={kwargs['creationflags']}") 200 | elif 'start_new_session' in kwargs: 201 | print(f"DEBUG: OT_QuitBlenderNoSave: Using start_new_session={kwargs['start_new_session']}") 202 | 203 | except Exception as e: 204 | if addon_prefs.debug: 205 | print(f"ERROR: OT_QuitBlenderNoSave: Failed to launch new Blender instance: {e}") 206 | else: 207 | if addon_prefs.debug: 208 | print(f"DEBUG: OT_QuitBlenderNoSave: Blender executable path not found or invalid: '{blender_exe}'. Skipping new instance launch.") 209 | 210 | if blender_will_save_on_quit: 211 | # This path is taken if invoke_confirm was accepted by the user. 212 | if addon_prefs.debug: 213 | print("DEBUG: OT_QuitBlenderNoSave: Quitting Blender. 'Save on Quit' is ON. " 214 | "User was warned; preferences WILL be saved by Blender.") 215 | # The warning should have made it clear that preferences *will* be saved. 216 | else: 217 | # 'Save on Quit' is OFF. 218 | if addon_prefs.debug: print("DEBUG: OT_QuitBlenderNoSave: Quitting Blender. 'Save on Quit' is OFF. Preferences will NOT be saved by Blender.") 219 | 220 | if addon_prefs.debug: 221 | if new_instance_launched_successfully: 222 | print("DEBUG: OT_QuitBlenderNoSave: New instance launch command succeeded. Proceeding to quit current instance.") 223 | else: 224 | print("DEBUG: OT_QuitBlenderNoSave: New instance launch failed or was skipped. Proceeding to quit current instance.") 225 | 226 | # --- Proceed with quitting the current Blender instance --- 227 | bpy.ops.wm.quit_blender() 228 | return {'FINISHED'} 229 | 230 | # This draw method is for the confirmation dialog when use_save_on_quit is True 231 | def draw(self, context): 232 | layout = self.layout 233 | col = layout.column() 234 | col.label(text="Blender's 'Save Preferences on Quit' is currently ENABLED.", icon='ERROR') 235 | col.separator() 236 | col.label(text="If you proceed, Blender will save its current in-memory preferences when quitting.") 237 | col.label(text="This will likely overwrite the 'userpref.blend' file you just restored.") 238 | col.separator() 239 | col.label(text="To ensure the restored 'userpref.blend' is used on next startup:") 240 | box = col.box() 241 | box.label(text="1. Select 'Cancel' on this dialog (see details below).") 242 | box.label(text="2. Go to: Edit > Preferences > Save & Load.") 243 | box.label(text="3. Uncheck the 'Save on Quit' option.") 244 | box.label(text="4. Manually quit Blender (File > Quit).") 245 | box.label(text="5. Restart Blender. Your restored preferences should now be active.") 246 | box.label(text=" (You can re-enable 'Save on Quit' after restarting, if desired).") 247 | col.separator() 248 | col.label(text=f"Choosing '{self.bl_label}' (OK) below will quit this Blender session,") 249 | col.label(text="and it WILL save its current preferences due to the global setting.") 250 | col.label(text="An attempt will then be made to start a new Blender instance.") 251 | col.separator() 252 | col.label(text="Choosing 'Cancel' will abort this quit/restart attempt by the addon.") 253 | 254 | 255 | class OT_CloseReportDialog(bpy.types.Operator): 256 | """Closes the report dialog without taking further action.""" 257 | bl_idname = "bm.close_report_dialog" 258 | bl_label = "Don't Quit Now" 259 | bl_options = {'INTERNAL'} 260 | 261 | def execute(self, context): 262 | # This operator's purpose is just to be a clickable item in the popup 263 | # that allows the popup to close without triggering the quit sequence. 264 | if prefs().debug: 265 | print("DEBUG: OT_CloseReportDialog executed (User chose not to quit/restart from report).") 266 | return {'FINISHED'} 267 | 268 | 269 | class OT_ShowFinalReport(bpy.types.Operator): 270 | """Operator to display a popup message. Used by timers for deferred reports.""" 271 | bl_idname = "bm.show_final_report" 272 | bl_label = "Show Operation Report" 273 | bl_options = {'INTERNAL'} # This operator is not meant to be called directly by the user from UI search 274 | 275 | # Static class variables to hold the report data 276 | _title: str = "Report" 277 | _icon: str = "INFO" 278 | _lines: list = [] 279 | _timer = None # Timer for the modal part 280 | _show_restart_button: bool = False 281 | _restart_operator_idname: str = "" 282 | 283 | @classmethod 284 | def set_report_data(cls, lines, title, icon, show_restart=False, restart_op_idname=""): 285 | """Sets the data to be displayed by the popup.""" 286 | cls._lines = lines 287 | cls._title = title 288 | cls._icon = icon 289 | cls._show_restart_button = show_restart 290 | cls._restart_operator_idname = restart_op_idname 291 | if prefs().debug: 292 | print(f"DEBUG: OT_ShowFinalReport.set_report_data: Title='{cls._title}', Icon='{cls._icon}', Lines={len(cls._lines)}, ShowRestart={cls._show_restart_button}, RestartOp='{cls._restart_operator_idname}'") 293 | 294 | def execute(self, context): #@NoSelf 295 | """Displays the popup report directly.""" 296 | if prefs().debug: 297 | print(f"DEBUG: OT_ShowFinalReport.execute: Displaying popup. Title='{OT_ShowFinalReport._title}'") 298 | 299 | # --- This function defines what's drawn in the popup --- 300 | def draw_for_popup(self_menu, context_inner): # 'self_menu' is the Menu instance, 'context_inner' is the context for the popup 301 | layout = self_menu.layout 302 | for line in OT_ShowFinalReport._lines: 303 | layout.label(text=line) 304 | 305 | if OT_ShowFinalReport._show_restart_button and OT_ShowFinalReport._restart_operator_idname: 306 | layout.separator() 307 | # Place the restart and close buttons in a single row for side-by-side layout 308 | row = layout.row(align=True) 309 | row.operator(OT_ShowFinalReport._restart_operator_idname, icon='FILE_REFRESH') 310 | row.operator(OT_CloseReportDialog.bl_idname, icon='CANCEL') 311 | 312 | context.window_manager.popup_menu(draw_for_popup, title=OT_ShowFinalReport._title, icon=OT_ShowFinalReport._icon) 313 | return {'FINISHED'} 314 | 315 | 316 | class OT_BackupManagerWindow(Operator): 317 | """Open the Backup Manager window.""" 318 | bl_idname = "bm.open_backup_manager_window" 319 | bl_label = "Backup Manager" 320 | bl_options = {'REGISTER'} # No UNDO needed for a UI window 321 | 322 | _cancelled: bool = False # Instance variable to track cancellation state 323 | 324 | def _update_window_tabs(self, context): 325 | """Ensures BM_Preferences.tabs is updated when this window's tabs change, triggering searches.""" 326 | # --- Early exit conditions for stale instance --- 327 | # Use getattr for robustness, in case _cancelled is not yet set on self during an early update call 328 | if getattr(self, '_cancelled', False): 329 | # Minimal logging if possible, avoid complex operations 330 | # print(f"DEBUG: OT_BackupManagerWindow._update_window_tabs - Bailing out: _cancelled is True. Self: {self}") 331 | return 332 | 333 | prefs_instance = None 334 | try: 335 | prefs_instance = prefs() 336 | if not prefs_instance: # If prefs() returns None or an invalid object 337 | # print(f"DEBUG: OT_BackupManagerWindow._update_window_tabs - Bailing out: prefs() returned None/invalid. Self: {self}") 338 | return 339 | except Exception as e: 340 | # If prefs() itself raises an exception (e.g., addon not found during unregister/register) 341 | print(f"ERROR: Backup Manager: Error in OT_BackupManagerWindow._update_window_tabs (accessing prefs): {e}. Self: {self}") 342 | return 343 | 344 | # --- Proceed with original logic if checks passed --- 345 | if prefs_instance.tabs != self.tabs: 346 | if prefs_instance.debug: # Safe to use prefs_instance.debug now 347 | print(f"DEBUG: OT_BackupManagerWindow._update_window_tabs: self.tabs ('{self.tabs}') != prefs.tabs ('{prefs_instance.tabs}'). Updating prefs.tabs. Self: {self}") 348 | try: 349 | prefs_instance.tabs = self.tabs # This will call BM_Preferences.update_version_list 350 | except Exception as e_update: 351 | # Catch errors during the actual update of prefs_instance.tabs 352 | if prefs_instance.debug: 353 | print(f"ERROR: Backup Manager: Error in OT_BackupManagerWindow._update_window_tabs (updating prefs.tabs): {e_update}. Self: {self}") 354 | # Decide if further action is needed, e.g., report error to user or log 355 | 356 | 357 | tabs: EnumProperty( 358 | name="Mode", 359 | items=[ 360 | ("BACKUP", "Backup", "Switch to Backup mode", "COLORSET_03_VEC", 0), 361 | ("RESTORE", "Restore", "Switch to Restore mode", "COLORSET_04_VEC", 1) 362 | ], 363 | default="BACKUP", 364 | update=_update_window_tabs 365 | ) 366 | 367 | def _draw_path_age(self, layout, path_to_check): 368 | """Helper to draw cached path age.""" 369 | prefs_instance = prefs() # Get prefs for debug flag 370 | if not path_to_check or not os.path.isdir(path_to_check): # Basic check before cache lookup 371 | layout.label(text="Last change: Path N/A") 372 | return 373 | display_text = BM_Preferences._age_cache.get(path_to_check) 374 | if display_text is None: 375 | display_text = "Last change: Calculating..." 376 | if prefs_instance.debug: 377 | print(f"DEBUG: OT_BackupManagerWindow._draw_path_age: No cache for '{path_to_check}', displaying 'Calculating...'") 378 | elif prefs_instance.debug: 379 | print(f"DEBUG: OT_BackupManagerWindow._draw_path_age: Using cached value for '{path_to_check}': {display_text}") 380 | layout.label(text=display_text) 381 | 382 | def _draw_path_size(self, layout, path_to_check): 383 | """Helper to draw cached path size.""" 384 | prefs_instance = prefs() # Get prefs for debug flag 385 | if not path_to_check or not os.path.isdir(path_to_check): # Basic check 386 | layout.label(text="Size: Path N/A") 387 | return 388 | display_text = BM_Preferences._size_cache.get(path_to_check) 389 | if display_text is None: 390 | display_text = "Size: Calculating..." 391 | if prefs_instance.debug: 392 | print(f"DEBUG: OT_BackupManagerWindow._draw_path_size: No cache for '{path_to_check}', displaying 'Calculating...'") 393 | elif prefs_instance.debug: 394 | print(f"DEBUG: OT_BackupManagerWindow._draw_path_size: Using cached value for '{path_to_check}': {display_text}") 395 | layout.label(text=display_text) 396 | 397 | def _draw_selection_toggles(self, layout_box, mode, prefs_instance): 398 | """Replicates BM_Preferences.draw_selection for the new window.""" 399 | prefix = "backup_" if mode == "BACKUP" else "restore_" 400 | 401 | row = layout_box.row(align=True) 402 | col1 = row.column() 403 | col1.prop(prefs_instance, f'{prefix}addons') 404 | col1.prop(prefs_instance, f'{prefix}extensions') 405 | col1.prop(prefs_instance, f'{prefix}presets') 406 | col1.prop(prefs_instance, f'{prefix}datafile') 407 | 408 | col2 = row.column() 409 | col2.prop(prefs_instance, f'{prefix}startup_blend') 410 | col2.prop(prefs_instance, f'{prefix}userpref_blend') 411 | col2.prop(prefs_instance, f'{prefix}workspaces_blend') 412 | 413 | col3 = row.column() 414 | col3.prop(prefs_instance, f'{prefix}cache') 415 | col3.prop(prefs_instance, f'{prefix}bookmarks') 416 | col3.prop(prefs_instance, f'{prefix}recentfiles') 417 | 418 | def _draw_backup_tab(self, layout, context, prefs_instance): 419 | """Draws the Backup tab content.""" 420 | row_main = layout.row(align=True) # Main row for From/To/Actions 421 | 422 | box_from = row_main.box() 423 | col_from = box_from.column() 424 | 425 | if not prefs_instance.advanced_mode: 426 | path_from_val = prefs_instance.blender_user_path 427 | col_from.label(text = "Backup From: " + str(prefs_instance.active_blender_version), icon='COLORSET_03_VEC') 428 | col_from.label(text = path_from_val) 429 | if prefs_instance.show_path_details: 430 | self._draw_path_age(col_from, path_from_val) 431 | self._draw_path_size(col_from, path_from_val) 432 | 433 | box_to = row_main.box() # Add box_to to row_main 434 | col_to = box_to.column() 435 | path_to_val = os.path.join(prefs_instance.backup_path, str(prefs_instance.active_blender_version)) if prefs_instance.backup_path else "N/A" 436 | col_to.label(text = "Backup To: " + str(prefs_instance.active_blender_version), icon='COLORSET_04_VEC') 437 | col_to.label(text = path_to_val) 438 | if prefs_instance.show_path_details: 439 | self._draw_path_age(col_to, path_to_val) 440 | self._draw_path_size(col_to, path_to_val) 441 | else: # Advanced mode 442 | # --- Backup From Box --- 443 | source_version_selected = prefs_instance.backup_versions # This is the string value of the selected item 444 | path_from_val = os.path.join(os.path.dirname(prefs_instance.blender_user_path), source_version_selected) if prefs_instance.blender_user_path and source_version_selected else "N/A" 445 | 446 | col_from.label(text="Backup From: " + source_version_selected, icon='COLORSET_03_VEC') 447 | col_from.label(text=path_from_val) 448 | if prefs_instance.show_path_details: self._draw_path_age(col_from, path_from_val) 449 | if prefs_instance.show_path_details: self._draw_path_size(col_from, path_from_val) 450 | col_from.prop(prefs_instance, 'backup_versions', text='Version' if prefs_instance.expand_version_selection else '', expand=prefs_instance.expand_version_selection) 451 | 452 | # --- Backup To Box --- 453 | box_to = row_main.box() # Add box_to to row_main 454 | col_to = box_to.column() 455 | if prefs_instance.custom_version_toggle: 456 | target_version_displayed = prefs_instance.custom_version 457 | path_to_val = os.path.join(prefs_instance.backup_path, target_version_displayed) if prefs_instance.backup_path and target_version_displayed else "N/A" 458 | col_to.label(text="Backup To: " + target_version_displayed, icon='COLORSET_04_VEC') 459 | col_to.label(text=path_to_val) 460 | if prefs_instance.show_path_details: self._draw_path_age(col_to, path_to_val) 461 | if prefs_instance.show_path_details: self._draw_path_size(col_to, path_to_val) 462 | col_to.prop(prefs_instance, 'custom_version', text='Version') 463 | else: # Not custom_version_toggle, use restore_versions for dropdown 464 | target_version_displayed = prefs_instance.restore_versions # This is the string value of the selected item 465 | path_to_val = os.path.join(prefs_instance.backup_path, target_version_displayed) if prefs_instance.backup_path and target_version_displayed else "N/A" 466 | col_to.label(text="Backup To: " + target_version_displayed, icon='COLORSET_04_VEC') 467 | col_to.label(text=path_to_val) 468 | if prefs_instance.show_path_details: self._draw_path_age(col_to, path_to_val) 469 | if prefs_instance.show_path_details: self._draw_path_size(col_to, path_to_val) 470 | col_to.prop(prefs_instance, 'restore_versions', text='Version' if prefs_instance.expand_version_selection else '', expand=prefs_instance.expand_version_selection) 471 | 472 | # --- Actions Column --- 473 | col_actions = row_main.column() # For main action buttons and global toggles 474 | col_actions.scale_x = 0.9 # Slightly narrower for this column 475 | col_actions.operator("bm.run_backup_manager", text="Backup Selected", icon='COLORSET_03_VEC').button_input = 'BACKUP' 476 | if prefs_instance.advanced_mode: 477 | col_actions.operator("bm.run_backup_manager", text="Backup All", icon='COLORSET_03_VEC').button_input = 'BATCH_BACKUP' 478 | col_actions.separator(factor=1.0) 479 | col_actions.prop(prefs_instance, 'dry_run') 480 | col_actions.prop(prefs_instance, 'clean_path') 481 | col_actions.prop(prefs_instance, 'advanced_mode') 482 | if prefs_instance.advanced_mode: 483 | col_actions.prop(prefs_instance, 'custom_version_toggle') 484 | col_actions.prop(prefs_instance, 'expand_version_selection') 485 | col_actions.separator(factor=1.0) 486 | col_actions.operator("bm.run_backup_manager", text="Delete Backup", icon='COLORSET_01_VEC').button_input = 'DELETE_BACKUP' 487 | 488 | # --- Selection Toggles (Advanced Mode Only) --- 489 | # Drawn *after* the main row is complete, and only if in advanced mode. 490 | if prefs_instance.advanced_mode: 491 | selection_toggles_box = layout.box() # Create a new box at the 'layout' (tab_content_box) level 492 | self._draw_selection_toggles(selection_toggles_box, "BACKUP", prefs_instance) 493 | 494 | def _draw_restore_tab(self, layout, context, prefs_instance): 495 | """Draws the Restore tab content.""" 496 | row_main = layout.row(align=True) # Main row for From/To/Actions 497 | 498 | box_from = row_main.box() 499 | col_from = box_from.column() 500 | 501 | if not prefs_instance.advanced_mode: 502 | path_from_val = os.path.join(prefs_instance.backup_path, str(prefs_instance.active_blender_version)) if prefs_instance.backup_path else "N/A" 503 | col_from.label(text = "Restore From: " + str(prefs_instance.active_blender_version), icon='COLORSET_04_VEC') 504 | col_from.label(text = path_from_val) 505 | if prefs_instance.show_path_details: self._draw_path_age(col_from, path_from_val); self._draw_path_size(col_from, path_from_val) 506 | 507 | box_to = row_main.box() # Add box_to to row_main 508 | col_to = box_to.column() 509 | path_to_val = prefs_instance.blender_user_path 510 | col_to.label(text = "Restore To: " + str(prefs_instance.active_blender_version), icon='COLORSET_03_VEC') 511 | col_to.label(text = path_to_val) 512 | if prefs_instance.show_path_details: self._draw_path_age(col_to, path_to_val); self._draw_path_size(col_to, path_to_val) 513 | else: # Advanced Mode 514 | source_ver = prefs_instance.restore_versions 515 | path_from_val = os.path.join(prefs_instance.backup_path, source_ver) if prefs_instance.backup_path and source_ver else "N/A" 516 | col_from.label(text="Restore From: " + source_ver, icon='COLORSET_04_VEC') 517 | col_from.label(text=path_from_val) 518 | if prefs_instance.show_path_details: self._draw_path_age(col_from, path_from_val); self._draw_path_size(col_from, path_from_val) 519 | col_from.prop(prefs_instance, 'restore_versions', text='Version' if prefs_instance.expand_version_selection else '', expand=prefs_instance.expand_version_selection) 520 | 521 | box_to = row_main.box() # Add box_to to row_main 522 | col_to = box_to.column() 523 | target_ver = prefs_instance.backup_versions 524 | path_to_val = os.path.join(os.path.dirname(prefs_instance.blender_user_path), target_ver) if prefs_instance.blender_user_path and target_ver else "N/A" 525 | col_to.label(text="Restore To: " + target_ver, icon='COLORSET_03_VEC') 526 | col_to.label(text=path_to_val) 527 | if prefs_instance.show_path_details: self._draw_path_age(col_to, path_to_val); self._draw_path_size(col_to, path_to_val) 528 | col_to.prop(prefs_instance, 'backup_versions', text='Version' if prefs_instance.expand_version_selection else '', expand=prefs_instance.expand_version_selection) 529 | 530 | # --- Actions Column --- 531 | col_actions = row_main.column() 532 | col_actions.scale_x = 0.9 533 | col_actions.operator("bm.run_backup_manager", text="Restore Selected", icon='COLORSET_04_VEC').button_input = 'RESTORE' 534 | if prefs_instance.advanced_mode: 535 | col_actions.operator("bm.run_backup_manager", text="Restore All", icon='COLORSET_04_VEC').button_input = 'BATCH_RESTORE' 536 | col_actions.separator(factor=1.0) 537 | col_actions.prop(prefs_instance, 'dry_run') 538 | col_actions.prop(prefs_instance, 'clean_path') 539 | col_actions.prop(prefs_instance, 'advanced_mode') 540 | if prefs_instance.advanced_mode: 541 | col_actions.prop(prefs_instance, 'expand_version_selection') 542 | 543 | # --- Selection Toggles (Advanced Mode Only) --- 544 | # Drawn *after* the main row is complete, and only if in advanced mode. 545 | if prefs_instance.advanced_mode: 546 | selection_toggles_box = layout.box() # Create a new box at the 'layout' (tab_content_box) level 547 | self._draw_selection_toggles(selection_toggles_box, "RESTORE", prefs_instance) 548 | 549 | def draw(self, context): # Standard signature for invoke_props_dialog 550 | layout = self.layout # Provided by invoke_props_dialog 551 | prefs_instance = None 552 | _debug_draw = False # Default 553 | 554 | # --- Early exit conditions for stale instance --- 555 | if self._cancelled: # Check if the instance itself was marked as cancelled 556 | layout.label(text="Window closing (operator cancelled)...") 557 | # print(f"DEBUG: OT_BackupManagerWindow.draw() - Bailing out: _cancelled is True. Self: {self}") 558 | return 559 | 560 | # Attempt to get preferences and set debug flag safely 561 | try: 562 | prefs_instance = prefs() # Get current addon preferences 563 | if not prefs_instance: # If prefs() returns None or an invalid object 564 | layout.label(text="Window closing (preferences unavailable)...") 565 | # print(f"DEBUG: OT_BackupManagerWindow.draw() - Bailing out: prefs() returned None/invalid. Self: {self}") 566 | return 567 | _debug_draw = prefs_instance.debug # Safe to access .debug now 568 | except Exception as e: 569 | # If prefs() itself raises an exception (e.g., addon not found during unregister/register) 570 | layout.label(text=f"Window closing (error accessing preferences: {e})...") 571 | # print(f"DEBUG: OT_BackupManagerWindow.draw() - Bailing out: Exception in prefs(): {e}. Self: {self}") 572 | return 573 | 574 | # --- Proceed with normal drawing if all checks passed --- 575 | try: 576 | _start_time_draw_obj = None # Use a different name to avoid conflict if prefs_instance is None initially 577 | 578 | if _debug_draw: 579 | # Debug output for draw() start 580 | timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] 581 | progress_val_str = f"{prefs_instance.operation_progress_value:.1f}%" if prefs_instance.show_operation_progress else "N/A (hidden)" 582 | op_message = prefs_instance.operation_progress_message if prefs_instance.show_operation_progress else "N/A (hidden)" 583 | print(f"DEBUG: [{timestamp}] OT_BackupManagerWindow.draw() CALLED. Progress: {progress_val_str}, Msg: '{op_message}', show_op_progress: {prefs_instance.show_operation_progress}, Tabs: {self.tabs}") 584 | _start_time_draw_obj = datetime.now() 585 | 586 | is_operation_running = prefs_instance.show_operation_progress 587 | 588 | 589 | # --- Top section for global settings --- 590 | box_top = layout.box() 591 | col_top = box_top.column(align=True) 592 | col_top.use_property_split = True 593 | col_top.separator() 594 | 595 | # System ID is always read-only display 596 | row_system_id = col_top.row() 597 | row_system_id.enabled = False 598 | row_system_id.prop(prefs_instance, "system_id") 599 | 600 | # Group settings that should be disabled during an operation 601 | settings_to_disable_group = col_top.column() 602 | settings_to_disable_group.enabled = not is_operation_running 603 | settings_to_disable_group.prop(prefs_instance, "use_system_id") 604 | settings_to_disable_group.prop(prefs_instance, 'backup_path') 605 | #col_top.prop(prefs_instance, 'ignore_files') # Still commented out 606 | settings_to_disable_group.prop(prefs_instance, 'show_path_details') 607 | 608 | # Debug 609 | col_top.prop(prefs_instance, 'debug') 610 | col_top.separator() 611 | 612 | if prefs_instance.debug: # Only show system paths if debug is enabled 613 | col_top.separator() 614 | 615 | # Blender System Paths (Read-Only) 616 | box_system_paths_display = col_top.box() 617 | col_system_paths_display = box_system_paths_display.column(align=True) 618 | col_system_paths_display.label(text="Blender System Paths (Read-Only - Debug):") 619 | 620 | # Blender Installation Path 621 | blender_install_path = os.path.dirname(bpy.app.binary_path) 622 | row_install = col_system_paths_display.row(align=True) 623 | row_install.label(text="Installation Path:") 624 | op_install = row_install.operator(preferences.OT_OpenPathInExplorer.bl_idname, icon='FILEBROWSER', text="") 625 | op_install.path_to_open = blender_install_path 626 | col_system_paths_display.label(text=blender_install_path) 627 | 628 | # User Version Folder Path 629 | row_user_version_folder = col_system_paths_display.row(align=True) 630 | row_user_version_folder.label(text="User Version Folder:") 631 | op_user_version_folder = row_user_version_folder.operator(preferences.OT_OpenPathInExplorer.bl_idname, icon='FILEBROWSER', text="") 632 | op_user_version_folder.path_to_open = prefs_instance.blender_user_path 633 | col_system_paths_display.label(text=prefs_instance.blender_user_path) 634 | 635 | # User Config Subfolder Path is already available via prefs_instance.config_path 636 | # No need to construct it manually here as it's already a preference property. 637 | # We can add it here if desired, similar to the preferences panel. 638 | 639 | 640 | 641 | # --- Save Preferences Button --- 642 | # Conditionally show the "Save Preferences" button. 643 | # Only show it if Blender's "Save Preferences on Quit" is OFF. 644 | prefs_main = context.preferences # Use bpy.context.preferences 645 | show_manual_save_button = True # Default to showing the button 646 | 647 | if prefs_main and hasattr(prefs_main, 'use_preferences_save'): 648 | if prefs_main.use_preferences_save: # If 'Auto-Save Preferences' is ON 649 | show_manual_save_button = False # Then DO NOT show the manual save button 650 | if _debug_draw: 651 | print("DEBUG: OT_BackupManagerWindow.draw(): Blender's 'Save on Quit' is ON. Hiding manual 'Save Preferences' button.") 652 | elif _debug_draw: # 'Save on Quit' is OFF 653 | print("DEBUG: OT_BackupManagerWindow.draw(): Blender's 'Save on Quit' is OFF. Showing manual 'Save Preferences' button.") 654 | elif _debug_draw: # Could not determine 'Save on Quit' state 655 | print("DEBUG: OT_BackupManagerWindow.draw(): Could not determine Blender's 'Save on Quit' state. Defaulting to show manual 'Save Preferences' button.") 656 | 657 | if show_manual_save_button: 658 | save_prefs_row = layout.row(align=True) 659 | save_prefs_row.enabled = not is_operation_running # Disable if operation is running 660 | 661 | # Check if preferences have unsaved changes (shows '*' in Blender UI) 662 | label_text = "Save Preferences" 663 | if bpy.context.preferences.is_dirty: 664 | label_text += " *" 665 | 666 | # Proper alignment for the button within the row 667 | save_prefs_row.label(text="") # Spacer on the left 668 | save_prefs_button_col = save_prefs_row.column() 669 | save_prefs_button_col.scale_x = 0.5 # Make button narrower 670 | save_prefs_button_col.operator("wm.save_userpref", text=label_text, icon='PREFERENCES') 671 | 672 | # --- Tabs for Backup/Restore --- 673 | layout.use_property_split = False 674 | layout.prop(self, "tabs", expand=False) # Use the operator's own tabs property 675 | 676 | # --- Progress UI --- 677 | if prefs_instance.show_operation_progress: 678 | op_status_box = layout.box() 679 | op_status_col = op_status_box.column(align=True) 680 | 681 | # Display the progress message 682 | if prefs_instance.operation_progress_message: 683 | op_status_col.label(text=prefs_instance.operation_progress_message) 684 | 685 | # Create a new row for the progress bar and abort button 686 | progress_row = op_status_col.row(align=True) 687 | 688 | # Display the progress value as a slider (without its own text label) 689 | # operation_progress_value is a 0.0-100.0 factor. The text="" hides the label to its left. 690 | progress_row.prop(prefs_instance, "operation_progress_value", slider=True, text="") 691 | 692 | # Abort button 693 | progress_row.operator("bm.abort_operation", text="", icon='CANCEL') # Text removed for compactness 694 | 695 | # --- Tab content for Backup/Restore --- 696 | tab_content_box = layout.box() # Box for the content of the selected tab 697 | tab_content_box.enabled = not is_operation_running # Disable tab content if operation is running 698 | 699 | if self.tabs == "BACKUP": 700 | self._draw_backup_tab(tab_content_box, context, prefs_instance) 701 | elif self.tabs == "RESTORE": 702 | self._draw_restore_tab(tab_content_box, context, prefs_instance) 703 | 704 | if _debug_draw and _start_time_draw_obj: 705 | _end_time_draw_obj = datetime.now() 706 | print(f"DEBUG: (took: {(_end_time_draw_obj - _start_time_draw_obj).total_seconds():.6f}s) OT_BackupManagerWindow.draw() END") 707 | print("-" * 70) # Add a separator line 708 | 709 | except Exception as e: 710 | print(f"ERROR: Backup Manager: Error during OT_BackupManagerWindow.draw() main block: {e}") 711 | layout.label(text=f"Error drawing Backup Manager window: {e}") 712 | 713 | 714 | def execute(self, context): 715 | prefs_instance = None 716 | try: 717 | prefs_instance = prefs() 718 | except Exception as e: 719 | # Log error and attempt to cancel gracefully 720 | print(f"ERROR: OT_BackupManagerWindow.execute() - Failed to get preferences: {e}. Self: {self}") 721 | self.cancel(context) # Ensure timer cleanup 722 | return {'CANCELLED'} # Indicate failure 723 | 724 | if prefs_instance.debug: 725 | print(f"DEBUG: OT_BackupManagerWindow.execute() ENTER. Context: {context}, Self: {self}") 726 | 727 | # Ensure timer is cleaned up if execute is called (e.g. by an "OK" button if one existed) 728 | if self._timer: 729 | context.window_manager.event_timer_remove(self._timer) 730 | self._timer = None 731 | if prefs_instance.debug: print(f"DEBUG: OT_BackupManagerWindow.execute() - Timer removed.") 732 | 733 | # Check prefs_instance again in case it was None from the try-except block 734 | # but cancel didn't lead to an immediate return (though it should). 735 | # This is more for defensive logging. 736 | if prefs_instance and prefs_instance.debug: 737 | print(f"DEBUG: OT_BackupManagerWindow.execute() EXIT. Returning {{'FINISHED'}}. Self: {self}") 738 | return {'FINISHED'} # Signal successful completion. 739 | 740 | def invoke(self, context, event): 741 | self._cancelled = False # Reset cancellation flag on new invocation 742 | 743 | prefs_instance = prefs() 744 | 745 | # Critical check: If preferences are not available, cancel immediately. 746 | if not prefs_instance: 747 | print(f"ERROR: OT_BackupManagerWindow.invoke() - Failed to get addon preferences. Cannot initialize window. Self: {self}") 748 | return {'CANCELLED'} 749 | 750 | # Now it's safe to use prefs_instance 751 | _debug_active = prefs_instance.debug # Store debug state for local use 752 | 753 | if _debug_active: 754 | print(f"DEBUG: OT_BackupManagerWindow.invoke() CALLED. Initializing tabs from prefs: {prefs_instance.tabs}") 755 | 756 | self.tabs = prefs_instance.tabs # Initialize window tabs from preferences 757 | 758 | # Explicitly trigger the version list update (which includes the SEARCH operator call) 759 | # and subsequent path detail scan (if show_path_details is true). 760 | # This ensures that the necessary scans are performed on first window open, 761 | # as the update chain via self.tabs -> _update_window_tabs -> prefs.tabs 762 | # might not fire if the tab values are already synchronized. 763 | if _debug_active: 764 | print(f"DEBUG: OT_BackupManagerWindow.invoke() - Explicitly calling prefs_instance.update_version_list(context) for initial scan.") 765 | prefs_instance._update_backup_path_and_versions(context) 766 | 767 | # Use invoke_props_dialog to open the window. 768 | # The operator's draw() method will be called by Blender to populate the dialog. 769 | result = context.window_manager.invoke_props_dialog(self, width=700) 770 | if _debug_active: 771 | print(f"DEBUG: OT_BackupManagerWindow.invoke() EXIT. invoke_props_dialog returned: {result}. Self: {self}") 772 | # If invoke_props_dialog returns {'RUNNING_MODAL'}, our modal() method will also run. 773 | return result 774 | 775 | # The modal() method is removed as the operator is no longer self-modal. 776 | # invoke_props_dialog handles the dialog's modality. 777 | 778 | def cancel(self, context): 779 | self._cancelled = True # Mark this instance as cancelled 780 | 781 | # Use the robust prefs() function from core.py 782 | _debug_active = False # Default to False for safety during cancel 783 | prefs_instance_for_cancel = None 784 | try: 785 | prefs_instance_for_cancel = prefs() 786 | if prefs_instance_for_cancel: 787 | _debug_active = prefs_instance_for_cancel.debug 788 | except Exception: 789 | pass # Ignore errors getting prefs during cancel, prioritize cleanup 790 | if _debug_active: print(f"DEBUG: OT_BackupManagerWindow.cancel() ENTER. Context: {context}, Self: {self}, _cancelled set to True.") 791 | 792 | # If an operation (from OT_BackupManager) is in progress, 793 | # DO NOT request it to abort just because this UI window is closing. 794 | # The OT_BackupManager is its own modal operator and can be cancelled 795 | # via its own ESC handling or the explicit Abort button (if window was open). 796 | try: 797 | if prefs_instance_for_cancel and prefs_instance_for_cancel.show_operation_progress: # Check if OT_BackupManager is likely active 798 | if _debug_active: 799 | print(f"DEBUG: OT_BackupManagerWindow.cancel() - Operation is in progress (show_operation_progress is True).") 800 | print(f"DEBUG: OT_BackupManagerWindow.cancel() - Window is closing, but the background operation will NOT be aborted by this window's cancellation.") 801 | # Explicitly DO NOT set: prefs_instance_for_cancel.abort_operation_requested = True 802 | elif prefs_instance_for_cancel: # No operation in progress 803 | if _debug_active: 804 | print(f"DEBUG: OT_BackupManagerWindow.cancel() - No operation in progress (show_operation_progress is False). Window closing normally.") 805 | except Exception as e: 806 | if _debug_active: print(f"DEBUG: OT_BackupManagerWindow.cancel() - Error accessing prefs_instance_for_cancel.show_operation_progress: {e}. Self: {self}") 807 | 808 | if _debug_active: 809 | print(f"DEBUG: OT_BackupManagerWindow.cancel() EXIT. Self: {self}") 810 | # Operator.cancel() is expected to do cleanup. 811 | # If invoke_props_dialog calls this, it handles the {'CANCELLED'} state internally. 812 | 813 | class OT_BackupManager(Operator): 814 | ''' run backup & restore ''' 815 | bl_idname = "bm.run_backup_manager" 816 | bl_label = "Blender Versions" 817 | # bl_options = {'REGISTER'} # Removed, not typically needed for modal operators unless specific registration behavior is desired. 818 | 819 | button_input: StringProperty() 820 | 821 | # --- Modal operator state variables --- 822 | _timer = None 823 | files_to_process: list = [] 824 | total_files: int = 0 825 | processed_files_count: int = 0 826 | current_source_path: str = "" 827 | current_target_path: str = "" 828 | current_operation_type: str = "" # 'BACKUP' or 'RESTORE' 829 | _progress_started_on_wm: bool = False # True if Blender's WM progress has been started 830 | # --- Batch operation state variables --- 831 | is_batch_operation: bool = False 832 | batch_operations_list: list = [] 833 | current_batch_item_index: int = 0 834 | total_batch_items: int = 0 835 | batch_report_lines: list = [] # To accumulate reports from each sub-operation 836 | 837 | ignore_backup = [] 838 | ignore_restore = [] 839 | def create_ignore_pattern(self): 840 | self.ignore_backup.clear() 841 | self.ignore_restore.clear() 842 | list = [x for x in regular_expression.split(',|\s+', prefs().ignore_files) if x!=''] 843 | for item in list: 844 | self.ignore_backup.append(item) 845 | self.ignore_restore.append(item) 846 | 847 | if not prefs().backup_bookmarks: 848 | self.ignore_backup.append('bookmarks.txt') 849 | if not prefs().restore_bookmarks: 850 | self.ignore_restore.append('bookmarks.txt') 851 | if not prefs().backup_recentfiles: 852 | self.ignore_backup.append('recent-files.txt') 853 | if not prefs().restore_recentfiles: 854 | self.ignore_restore.append('recent-files.txt') 855 | 856 | if not prefs().backup_startup_blend: 857 | self.ignore_backup.append('startup.blend') 858 | if not prefs().restore_startup_blend: 859 | self.ignore_restore.append('startup.blend') 860 | if not prefs().backup_userpref_blend: 861 | self.ignore_backup.append('userpref.blend') 862 | if not prefs().restore_userpref_blend: 863 | self.ignore_restore.append('userpref.blend') 864 | if not prefs().backup_workspaces_blend: 865 | self.ignore_backup.append('workspaces.blend') 866 | if not prefs().restore_workspaces_blend: 867 | self.ignore_restore.append('workspaces.blend') 868 | 869 | if not prefs().backup_cache: 870 | self.ignore_backup.append('cache') 871 | if not prefs().restore_cache: 872 | self.ignore_restore.append('cache') 873 | 874 | if not prefs().backup_datafile: 875 | self.ignore_backup.append('datafiles') 876 | if not prefs().restore_datafile: 877 | self.ignore_restore.append('datafiles') 878 | 879 | if not prefs().backup_addons: 880 | self.ignore_backup.append('addons') 881 | if not prefs().restore_addons: 882 | self.ignore_restore.append('addons') 883 | 884 | if not prefs().backup_extensions: 885 | self.ignore_backup.append('extensions') 886 | if not prefs().restore_extensions: 887 | self.ignore_restore.append('extensions') 888 | 889 | if not prefs().backup_presets: 890 | self.ignore_backup.append('presets') 891 | if not prefs().restore_presets: 892 | self.ignore_restore.append('presets') 893 | 894 | def cancel(self, context): 895 | """Ensures timer and progress UI are cleaned up if the operator is cancelled externally.""" 896 | # Use the robust prefs() function from core.py 897 | _debug_active = False # Default to False for safety during cancel 898 | prefs_instance_for_cancel = None 899 | try: 900 | prefs_instance_for_cancel = prefs() 901 | if prefs_instance_for_cancel: 902 | _debug_active = prefs_instance_for_cancel.debug 903 | except Exception: 904 | pass # Ignore errors getting prefs during cancel, prioritize cleanup 905 | 906 | if self._timer: 907 | try: 908 | context.window_manager.event_timer_remove(self._timer) 909 | if _debug_active: print(f"DEBUG: OT_BackupManager.cancel(): Timer removed.") 910 | except Exception as e: 911 | if _debug_active: print(f"DEBUG: OT_BackupManager.cancel(): Error removing timer: {e}") 912 | self._timer = None 913 | 914 | # Reset UI state related to this operator's modal operation 915 | try: 916 | if prefs_instance_for_cancel: 917 | prefs_instance_for_cancel.show_operation_progress = False 918 | prefs_instance_for_cancel.operation_progress_message = f"{self.current_operation_type if hasattr(self, 'current_operation_type') and self.current_operation_type else 'Operation'} cancelled (operator cleanup)." 919 | if self.is_batch_operation: 920 | prefs_instance_for_cancel.operation_progress_message = f"Batch operation cancelled." 921 | self.is_batch_operation = False # Reset batch flag 922 | 923 | prefs_instance_for_cancel.operation_progress_value = 0.0 # Reset progress value 924 | prefs_instance_for_cancel.abort_operation_requested = False # Reset this flag too 925 | if _debug_active: print(f"DEBUG: OT_BackupManager.cancel(): show_operation_progress and abort_operation_requested reset.") 926 | except Exception as e: 927 | if _debug_active: print(f"DEBUG: OT_BackupManager.cancel(): Error resetting preference flags: {e}") 928 | if _debug_active: print(f"DEBUG: OT_BackupManager.cancel() EXIT.") 929 | # Blender expects cancel() to return None 930 | 931 | 932 | @staticmethod 933 | def ShowReport_static(message = [], title = "Message Box", icon = 'INFO'): 934 | def draw(self_popup, context): # self_popup refers to the Menu instance for the popup 935 | # This function is kept for direct calls, but deferred calls will use BM_MT_PopupMessage 936 | for i in message: 937 | self_popup.layout.label(text=i) 938 | bpy.context.window_manager.popup_menu(draw, title = title, icon = icon) 939 | 940 | @staticmethod 941 | def _deferred_show_report_static(message_lines, title, icon, show_restart=False, restart_op_idname=""): 942 | if prefs().debug: 943 | print(f"DEBUG: _deferred_show_report_static: Preparing to invoke bm.show_final_report. Title='{title}', ShowRestart={show_restart}, RestartOp='{restart_op_idname}'") 944 | OT_ShowFinalReport.set_report_data(lines=message_lines, 945 | title=title, 946 | icon=icon, 947 | show_restart=show_restart, 948 | restart_op_idname=restart_op_idname) 949 | bpy.ops.bm.show_final_report('EXEC_DEFAULT') # Changed from INVOKE_SCREEN 950 | return None # Stop the timer 951 | 952 | # Keep the instance method for direct calls if needed, though static is preferred for deferred. 953 | def ShowReport(self, message = None, title = "Message Box", icon = 'INFO'): 954 | OT_BackupManager.ShowReport_static(message, title, icon) 955 | 956 | def _prepare_file_list(self): 957 | """Scans source_path and populates self.files_to_process and self.total_files.""" 958 | self.files_to_process.clear() 959 | self.total_files = 0 960 | self.processed_files_count = 0 961 | 962 | if not self.current_source_path or not os.path.isdir(self.current_source_path): 963 | self.report({'WARNING'}, f"Source path does not exist or is not a directory: {self.current_source_path}") 964 | return False 965 | 966 | self.create_ignore_pattern() 967 | current_ignore_list = self.ignore_backup if self.current_operation_type == 'BACKUP' else self.ignore_restore 968 | 969 | if prefs().debug: 970 | print(f"Preparing file list for {self.current_operation_type}") 971 | print(f"Source: {self.current_source_path}") 972 | print(f"Target: {self.current_target_path}") 973 | print(f"Ignore list: {current_ignore_list}") 974 | 975 | for dirpath, dirnames, filenames in os.walk(self.current_source_path): 976 | # Prune dirnames based on ignore list 977 | dirnames[:] = [d for d in dirnames if not any(fnmatch.fnmatch(d, pat) for pat in current_ignore_list)] 978 | 979 | for filename in filenames: 980 | if any(fnmatch.fnmatch(filename, pat) for pat in current_ignore_list): 981 | continue 982 | 983 | src_file = os.path.join(dirpath, filename) 984 | relative_dir = os.path.relpath(dirpath, self.current_source_path) 985 | # On Windows, relpath might return '.' for the top level, handle this. 986 | if relative_dir == '.': 987 | dest_file = os.path.join(self.current_target_path, filename) 988 | else: 989 | dest_file = os.path.join(self.current_target_path, relative_dir, filename) 990 | 991 | # This check prevents copying a file onto itself if src and dest resolve to the same path. 992 | if os.path.normpath(src_file) == os.path.normpath(dest_file): 993 | if prefs().debug: print(f"Skipping copy, source and destination are the same file: {src_file}") 994 | continue 995 | 996 | self.files_to_process.append((src_file, dest_file)) 997 | 998 | self.total_files = len(self.files_to_process) 999 | if prefs().debug: 1000 | print(f"Total files to process: {self.total_files}") 1001 | return True 1002 | 1003 | def _process_next_batch_item_or_finish(self, context): 1004 | """ 1005 | Sets up the next item in a batch operation for modal processing, 1006 | or finalizes the batch if all items are done. 1007 | Returns {'RUNNING_MODAL'} if a new item is started modally, 1008 | {'FINISHED'} if batch is complete or no items to process initially. 1009 | """ 1010 | pref_instance = prefs() # Get fresh preferences 1011 | 1012 | if self.current_batch_item_index < self.total_batch_items: 1013 | source_path, target_path, op_type, version_name = self.batch_operations_list[self.current_batch_item_index] 1014 | self.current_source_path = source_path 1015 | self.current_target_path = target_path 1016 | self.current_operation_type = op_type 1017 | 1018 | item_name_for_log = version_name # Use the version name for logging 1019 | 1020 | if pref_instance.clean_path and os.path.exists(self.current_target_path) and self.current_operation_type == 'BACKUP': 1021 | if pref_instance.debug: print(f"DEBUG: Batch: Attempting to clean path for {item_name_for_log}: {self.current_target_path}") 1022 | try: 1023 | if not pref_instance.dry_run: shutil.rmtree(self.current_target_path) 1024 | cleaned_msg = f"Cleaned path for {item_name_for_log}: {self.current_target_path}" 1025 | if pref_instance.debug or pref_instance.dry_run: print(cleaned_msg) 1026 | self.batch_report_lines.append(f"INFO: {cleaned_msg}") 1027 | except OSError as e: 1028 | fail_clean_msg = f"Failed to clean path for {item_name_for_log} ({self.current_target_path}): {e}" 1029 | if pref_instance.debug: print(f"ERROR: {fail_clean_msg}") 1030 | self.batch_report_lines.append(f"WARNING: {fail_clean_msg}") 1031 | 1032 | if not self._prepare_file_list(): # Populates self.files_to_process, self.total_files 1033 | err_msg = f"Batch item {self.current_batch_item_index + 1}/{self.total_batch_items} ({op_type} {item_name_for_log}): Error preparing file list. Skipping." 1034 | self.report({'WARNING'}, err_msg) 1035 | self.batch_report_lines.append(f"WARNING: {err_msg}") 1036 | pref_instance.operation_progress_message = err_msg 1037 | self.current_batch_item_index += 1 1038 | return self._process_next_batch_item_or_finish(context) # Try next 1039 | 1040 | if self.total_files == 0: 1041 | no_files_msg = f"Batch item {self.current_batch_item_index + 1}/{self.total_batch_items} ({op_type} {item_name_for_log}): No files to process. Skipping." 1042 | self.report({'INFO'}, no_files_msg) 1043 | self.batch_report_lines.append(f"INFO: {no_files_msg}") 1044 | pref_instance.operation_progress_message = no_files_msg 1045 | self.current_batch_item_index += 1 1046 | return self._process_next_batch_item_or_finish(context) # Try next 1047 | 1048 | # Item has files, set up for modal processing 1049 | self.processed_files_count = 0 # Reset for the new item 1050 | initial_message = f"Batch {self.current_operation_type} ({self.current_batch_item_index + 1}/{self.total_batch_items} - {item_name_for_log}): Starting... ({self.total_files} files)" 1051 | self.report({'INFO'}, initial_message) # Report to Blender status bar 1052 | pref_instance.show_operation_progress = True 1053 | pref_instance.operation_progress_message = initial_message 1054 | pref_instance.operation_progress_value = 0.0 1055 | 1056 | if self._timer is None: 1057 | self._timer = context.window_manager.event_timer_add(0.0, window=context.window) 1058 | if pref_instance.debug: print(f"DEBUG: Batch: Timer ADDED for item {self.current_batch_item_index + 1} ('{item_name_for_log}')") 1059 | # Modal handler should already be active from the initial execute call for the batch. 1060 | return {'RUNNING_MODAL'} # Signal that an item is ready for modal processing 1061 | else: 1062 | # All batch items processed 1063 | self.is_batch_operation = False # Reset flag 1064 | final_batch_message = f"Batch operation complete. Processed {self.total_batch_items} items." 1065 | self.report({'INFO'}, final_batch_message) 1066 | 1067 | report_title = "Batch Operation Report" 1068 | overall_op_type = "Operation" 1069 | if self.batch_operations_list: 1070 | overall_op_type = self.batch_operations_list[0][2] # Get op_type from first item 1071 | report_title = f"Batch {overall_op_type.capitalize()} Report" 1072 | 1073 | show_restart_btn_batch = False 1074 | if overall_op_type == 'RESTORE': # Show restart info even on dry run for simulation 1075 | self.batch_report_lines.append("") # Add a blank line for spacing 1076 | self.batch_report_lines.append("IMPORTANT: For restored settings to fully apply, this Blender session must be ended.") 1077 | self.batch_report_lines.append(f"Use the '{OT_QuitBlenderNoSave.bl_label}' button in the report.") 1078 | show_restart_btn_batch = True 1079 | 1080 | final_report_lines = [final_batch_message] + self.batch_report_lines[:] 1081 | bpy.app.timers.register( 1082 | lambda final_report_lines=final_report_lines, report_title=report_title, show_restart_btn_batch=show_restart_btn_batch: 1083 | OT_BackupManager._deferred_show_report_static( 1084 | final_report_lines, report_title, 'INFO', show_restart=show_restart_btn_batch, restart_op_idname="bm.quit_blender_no_save" 1085 | ), 1086 | first_interval=0.01 1087 | ) 1088 | 1089 | pref_instance.show_operation_progress = False 1090 | pref_instance.operation_progress_message = final_batch_message 1091 | pref_instance.operation_progress_value = 100.0 1092 | pref_instance.abort_operation_requested = False # Reset abort flag 1093 | 1094 | if self._timer: # Clean up timer if it was from the last item 1095 | context.window_manager.event_timer_remove(self._timer) 1096 | self._timer = None 1097 | return {'FINISHED'} 1098 | 1099 | def modal(self, context, event): 1100 | pref_instance = prefs() # Get fresh preferences 1101 | # Capture the state of the abort request flag at the beginning of this modal event 1102 | was_aborted_by_ui_button = pref_instance.abort_operation_requested 1103 | 1104 | # Check for abort request first or ESC key 1105 | # Or if all files are processed (files_to_process is empty AND processed_files_count matches total_files) 1106 | # Or if total_files was 0 to begin with (and processed is also 0) 1107 | if was_aborted_by_ui_button or event.type == 'ESC' or \ 1108 | (not self.files_to_process and self.processed_files_count >= self.total_files and self.total_files > 0) or \ 1109 | (self.total_files == 0 and self.processed_files_count == 0): # Handles case of no files to process initially 1110 | 1111 | # Timer for the *just completed* item (or an item that had 0 files) 1112 | if self._timer: 1113 | context.window_manager.event_timer_remove(self._timer) 1114 | self._timer = None 1115 | if pref_instance.debug: print(f"DEBUG: OT_BackupManager.modal(): Timer removed for completed/cancelled item.") 1116 | 1117 | # Reset the flag now that its state (was_aborted_by_ui_button) has been used for the decision to exit the modal. 1118 | if was_aborted_by_ui_button: 1119 | pref_instance.abort_operation_requested = False # Reset for next potential operation 1120 | 1121 | if event.type == 'ESC' or was_aborted_by_ui_button: 1122 | op_description = f"{self.current_operation_type}" 1123 | if self.is_batch_operation: 1124 | op_description = f"Batch {self.current_operation_type} (item {self.current_batch_item_index + 1}/{self.total_batch_items})" 1125 | 1126 | cancel_message = f"{op_description} cancelled by user." 1127 | self.report({'WARNING'}, cancel_message) 1128 | bpy.app.timers.register(lambda: OT_BackupManager._deferred_show_report_static([cancel_message], "Operation Cancelled", "WARNING"), first_interval=0.01) 1129 | 1130 | pref_instance.operation_progress_message = cancel_message 1131 | pref_instance.operation_progress_value = 0.0 1132 | pref_instance.show_operation_progress = False 1133 | self.is_batch_operation = False # Ensure batch mode is exited 1134 | return {'CANCELLED'} 1135 | else: # Operation completed successfully or no files to process 1136 | # This block handles completion of an individual item (could be part of a batch or a single op) 1137 | completion_status_item = "Dry run complete" if pref_instance.dry_run else "Complete" 1138 | display_processed_count = min(self.processed_files_count, self.total_files) 1139 | version_name_for_item_report = os.path.basename(self.current_source_path) if self.current_operation_type == 'BACKUP' else os.path.basename(self.current_target_path) 1140 | if self.is_batch_operation and self.current_batch_item_index < self.total_batch_items: 1141 | version_name_for_item_report = self.batch_operations_list[self.current_batch_item_index][3] # Get version_name 1142 | 1143 | item_report_msg = f"Item '{version_name_for_item_report}' ({self.current_operation_type}) {completion_status_item.lower()}: {display_processed_count}/{self.total_files} files." 1144 | if pref_instance.dry_run and self.total_files > 0: item_report_msg += " (Dry Run)" 1145 | 1146 | if self.is_batch_operation: 1147 | self.batch_report_lines.append(f"INFO: {item_report_msg}") 1148 | if pref_instance.debug: print(f"DEBUG: Batch item reported: {item_report_msg}") 1149 | 1150 | self.current_batch_item_index += 1 1151 | result_next_item = self._process_next_batch_item_or_finish(context) 1152 | 1153 | if result_next_item == {'RUNNING_MODAL'}: 1154 | # New item is set up, its timer is running. Modal loop continues. 1155 | return {'PASS_THROUGH'} 1156 | else: # {'FINISHED'} - batch fully complete 1157 | # _process_next_batch_item_or_finish handled final report and prefs update 1158 | return {'FINISHED'} 1159 | else: # Single operation completed 1160 | # Initialize show_restart_btn and prepare report_message_lines *before* scheduling the report 1161 | show_restart_btn = False 1162 | report_message_lines = [ 1163 | f"{self.current_operation_type} {completion_status_item.lower()}.", 1164 | f"{display_processed_count}/{self.total_files} files processed." 1165 | ] 1166 | if self.current_source_path: report_message_lines.append(f"Source: {self.current_source_path}") 1167 | if self.current_target_path: report_message_lines.append(f"Target: {self.current_target_path}") 1168 | 1169 | report_message_lines = [ 1170 | f"{self.current_operation_type} {completion_status_item.lower()}.", 1171 | f"{display_processed_count}/{self.total_files} files processed." 1172 | ] 1173 | if self.current_source_path: report_message_lines.append(f"Source: {self.current_source_path}") 1174 | if self.current_target_path: report_message_lines.append(f"Target: {self.current_target_path}") 1175 | if pref_instance.dry_run and self.total_files > 0: 1176 | report_message_lines.append("(Dry Run - No files were actually copied/deleted)") 1177 | 1178 | # Add restart instructions if it's a successful non-dry run RESTORE 1179 | if self.current_operation_type == 'RESTORE': # Show restart info even on dry run for simulation 1180 | report_message_lines.append("") # Add a blank line for spacing 1181 | report_message_lines.append("IMPORTANT: For restored settings to fully apply, this Blender session must be ended.") 1182 | report_message_lines.append(f"Use the '{OT_QuitBlenderNoSave.bl_label}' button below.") 1183 | show_restart_btn = True 1184 | 1185 | report_icon = 'INFO' 1186 | if self.current_operation_type == 'BACKUP': report_icon = 'COLORSET_03_VEC' 1187 | elif self.current_operation_type == 'RESTORE': report_icon = 'COLORSET_04_VEC' 1188 | 1189 | # Capture self.current_operation_type for the lambda 1190 | op_type_for_report_title = self.current_operation_type 1191 | 1192 | bpy.app.timers.register(lambda: OT_BackupManager._deferred_show_report_static( 1193 | report_message_lines, f"{op_type_for_report_title} Report", report_icon, 1194 | show_restart=show_restart_btn, restart_op_idname="bm.quit_blender_no_save" 1195 | ), first_interval=0.01) 1196 | self.report({'INFO'}, " ".join(report_message_lines)) 1197 | 1198 | pref_instance.operation_progress_message = f"{self.current_operation_type} {completion_status_item.lower()}." 1199 | pref_instance.operation_progress_value = 100.0 1200 | pref_instance.show_operation_progress = False 1201 | pref_instance.abort_operation_requested = False # Reset abort flag 1202 | return {'FINISHED'} 1203 | 1204 | 1205 | if event.type == 'TIMER': 1206 | if not self.files_to_process: 1207 | # This state (timer event but no files left) should lead to FINISHED via the top condition 1208 | # in the next event cycle. Update progress one last time for safety. 1209 | if self.total_files > 0: 1210 | current_progress_val = (self.processed_files_count / self.total_files) * 100.0 1211 | else: # No files to begin with 1212 | current_progress_val = 100.0 1213 | 1214 | finalizing_msg = f"{self.current_operation_type}: {self.processed_files_count}/{self.total_files} files ({current_progress_val:.1f}%) - Finalizing..." 1215 | if self.is_batch_operation: 1216 | version_name_finalize = self.batch_operations_list[self.current_batch_item_index][3] if self.current_batch_item_index < self.total_batch_items else "item" 1217 | finalizing_msg = f"Batch {self.current_operation_type} ({self.current_batch_item_index + 1}/{self.total_batch_items} - {version_name_finalize}): Finalizing..." 1218 | 1219 | pref_instance.operation_progress_message = finalizing_msg 1220 | pref_instance.operation_progress_value = current_progress_val 1221 | return {'PASS_THROUGH'} # Let the next cycle handle termination via top conditions 1222 | 1223 | # Process a batch of files 1224 | for _ in range(BM_Preferences.FILES_PER_TICK_MODAL_OP): # Use constant from preferences 1225 | if not self.files_to_process: 1226 | break # No more files in the list for this tick 1227 | 1228 | src_file, dest_file = self.files_to_process.pop(0) 1229 | 1230 | if not prefs().dry_run: 1231 | try: 1232 | os.makedirs(os.path.dirname(dest_file), exist_ok=True) 1233 | shutil.copy2(src_file, dest_file) 1234 | except (OSError, shutil.Error) as e: # Catch more specific errors 1235 | print(f"Error copying {src_file} to {dest_file}: {e}") 1236 | # Consider collecting errors for a summary report 1237 | 1238 | self.processed_files_count += 1 1239 | 1240 | # Update progress after processing the batch 1241 | if self.total_files > 0: 1242 | current_progress_val = (self.processed_files_count / self.total_files) * 100.0 1243 | else: 1244 | current_progress_val = 100.0 # Should be caught by initial total_files == 0 check 1245 | 1246 | # Update the message string for window label and status bar 1247 | progress_display_message = f"{self.current_operation_type}: {self.processed_files_count}/{self.total_files} files ({current_progress_val:.1f}%)" 1248 | if self.is_batch_operation: 1249 | version_name_progress = "item" 1250 | if self.current_batch_item_index < len(self.batch_operations_list): # Check bounds 1251 | version_name_progress = self.batch_operations_list[self.current_batch_item_index][3] # version_name 1252 | 1253 | progress_display_message = ( 1254 | f"Batch {self.current_operation_type} ({self.current_batch_item_index + 1}/{self.total_batch_items} - {version_name_progress}): " 1255 | f"{self.processed_files_count}/{self.total_files} files ({current_progress_val:.1f}%)" 1256 | ) 1257 | pref_instance.operation_progress_message = progress_display_message 1258 | pref_instance.operation_progress_value = current_progress_val 1259 | if pref_instance.debug: 1260 | timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] 1261 | print(f"DEBUG: [{timestamp}] OT_BackupManager.modal() (TIMER) updated progress to: {pref_instance.operation_progress_value:.1f}%, Msg: '{pref_instance.operation_progress_message}'") 1262 | 1263 | # Force redraw of UI to show progress, including the Backup Manager window if it's open 1264 | for wm_window_iter in context.window_manager.windows: 1265 | for area_iter in wm_window_iter.screen.areas: 1266 | area_iter.tag_redraw() 1267 | if pref_instance.debug: 1268 | # This log can be very verbose, so it's commented out by default. 1269 | # print(f"DEBUG: OT_BackupManager.modal() (TIMER) tagged all areas for redraw at {datetime.now().strftime('%H:%M:%S.%f')[:-3]}.") 1270 | pass 1271 | 1272 | return {'PASS_THROUGH'} # Allow other events to be processed 1273 | 1274 | def execute(self, context): 1275 | pref_instance = prefs() 1276 | pref_backup_versions = BM_Preferences.backup_version_list 1277 | pref_restore_versions = BM_Preferences.restore_version_list 1278 | pref_restore_versions = BM_Preferences.restore_version_list 1279 | 1280 | if pref_instance.debug: 1281 | print("\n\nbutton_input: ", self.button_input) 1282 | 1283 | if pref_instance.backup_path: 1284 | self.current_operation_type = "" # Reset for single ops 1285 | self.is_batch_operation = False # Reset for single ops 1286 | 1287 | if self.button_input in {'BACKUP', 'RESTORE'}: 1288 | self.current_operation_type = self.button_input 1289 | if not pref_instance.advanced_mode: 1290 | if self.button_input == 'BACKUP': 1291 | self.current_source_path = pref_instance.blender_user_path 1292 | self.current_target_path = os.path.join(pref_instance.backup_path, str(pref_instance.active_blender_version)) 1293 | elif self.button_input == 'RESTORE': 1294 | # --- Temporarily disable 'Save on Quit' for RESTORE operation --- 1295 | prefs_main = context.preferences # Use bpy.context.preferences 1296 | if prefs_main and hasattr(prefs_main, 'use_preferences_save'): 1297 | if prefs_main.use_preferences_save: # Only change if it was True 1298 | prefs_main.use_preferences_save = False 1299 | if pref_instance.debug: 1300 | print(f"DEBUG: OT_BackupManager.execute RESTORE (non-advanced): Temporarily disabled 'Save Preferences on Quit'.") 1301 | elif pref_instance.debug: 1302 | print(f"DEBUG: OT_BackupManager.execute RESTORE (non-advanced): Could not access 'use_save_on_quit'.") 1303 | # --- Set paths for non-advanced RESTORE --- 1304 | self.current_source_path = os.path.join(pref_instance.backup_path, str(pref_instance.active_blender_version)) 1305 | self.current_target_path = pref_instance.blender_user_path 1306 | else: 1307 | if self.button_input == 'BACKUP': # Advanced Mode Backup 1308 | self.current_source_path = os.path.join(os.path.dirname(pref_instance.blender_user_path), pref_instance.backup_versions) 1309 | if pref_instance.custom_version_toggle: 1310 | self.current_target_path = os.path.join(pref_instance.backup_path, str(pref_instance.custom_version)) 1311 | else: 1312 | # Corrected: If not custom, target for backup should be based on source version name 1313 | self.current_target_path = os.path.join(pref_instance.backup_path, pref_instance.backup_versions) 1314 | elif self.button_input == 'RESTORE': 1315 | # --- Temporarily disable 'Save on Quit' for RESTORE operation (Advanced) --- 1316 | prefs_main = context.preferences # Use bpy.context.preferences 1317 | if prefs_main and hasattr(prefs_main, 'use_preferences_save'): 1318 | if prefs_main.use_preferences_save: # Only change if it was True 1319 | prefs_main.use_preferences_save = False 1320 | if pref_instance.debug: 1321 | print(f"DEBUG: OT_BackupManager.execute RESTORE (advanced): Temporarily disabled 'Save Preferences on Quit'.") 1322 | elif pref_instance.debug: 1323 | print(f"DEBUG: OT_BackupManager.execute RESTORE (advanced): Could not access 'use_save_on_quit'.") 1324 | # --- Set paths for advanced RESTORE --- 1325 | self.current_source_path = os.path.join(pref_instance.backup_path, pref_instance.restore_versions) 1326 | self.current_target_path = os.path.join(os.path.dirname(pref_instance.blender_user_path), pref_instance.backup_versions) 1327 | 1328 | if prefs().clean_path and os.path.exists(self.current_target_path) and self.button_input == 'BACKUP': # Clean only for backup 1329 | if prefs().debug: print(f"Attempting to clean path: {self.current_target_path}") 1330 | try: 1331 | if not prefs().dry_run: shutil.rmtree(self.current_target_path) 1332 | print(f"Cleaned path: {self.current_target_path}") 1333 | except OSError as e: 1334 | print(f"Failed to clean path {self.current_target_path}: {e}") 1335 | self.report({'WARNING'}, f"Failed to clean {self.current_target_path}: {e}") 1336 | 1337 | if not self._prepare_file_list(): # Populates self.files_to_process 1338 | return {'CANCELLED'} 1339 | 1340 | if self.total_files == 0: # Handle case where no files are found to process 1341 | report_message = f"No files to {self.current_operation_type.lower()}" 1342 | if self.current_source_path: 1343 | report_message += f" from {self.current_source_path}" 1344 | 1345 | if pref_instance.dry_run: # Clarify dry run message for 0 files 1346 | report_message += " (Dry Run - no files would have been processed)." 1347 | else: 1348 | report_message += "." 1349 | 1350 | 1351 | self.report({'INFO'}, report_message) # Report to Blender status bar 1352 | pref_instance.show_operation_progress = False # No modal progress needed 1353 | pref_instance.operation_progress_message = report_message # For window if open 1354 | 1355 | # Determine title and icon for the deferred report 1356 | op_type_for_report = op_type_for_report = self.current_operation_type or "Operation" 1357 | icon_for_report = 'INFO' # Default 1358 | if self.current_operation_type == 'BACKUP': icon_for_report = 'COLORSET_03_VEC' 1359 | elif self.current_operation_type == 'RESTORE': icon_for_report = 'COLORSET_04_VEC' 1360 | 1361 | # Capture values for the lambda to ensure they are correct at execution time 1362 | _msg_lines = report_message.split('\n') 1363 | _title = f"{op_type_for_report} Report" 1364 | _icon = icon_for_report 1365 | 1366 | bpy.app.timers.register(lambda: OT_BackupManager._deferred_show_report_static( 1367 | _msg_lines, _title, _icon 1368 | ), first_interval=0.01) 1369 | return {'FINISHED'} 1370 | 1371 | initial_message = f"Starting {self.current_operation_type}... ({self.total_files} files)" 1372 | self.report({'INFO'}, initial_message) # Report initial status to Blender status bar 1373 | 1374 | # Set preferences for the addon window's display 1375 | pref_instance.show_operation_progress = True 1376 | pref_instance.operation_progress_message = initial_message # For the window label 1377 | pref_instance.operation_progress_value = 0.0 # Initialize progress value 1378 | 1379 | self._timer = context.window_manager.event_timer_add(0.0, window=context.window) # Adjusted interval 1380 | 1381 | context.window_manager.modal_handler_add(self) 1382 | return {'RUNNING_MODAL'} 1383 | 1384 | elif self.button_input == 'BATCH_BACKUP': 1385 | self.is_batch_operation = True 1386 | self.batch_operations_list.clear() 1387 | self.batch_report_lines.clear() 1388 | for version in pref_backup_versions: # Iterate over the list from preferences 1389 | version_name = version[0] 1390 | source_path = os.path.join(os.path.dirname(pref_instance.blender_user_path), version_name) 1391 | target_path = os.path.join(pref_instance.backup_path, version_name) 1392 | self.batch_operations_list.append((source_path, target_path, 'BACKUP', version_name)) 1393 | self.total_batch_items = len(self.batch_operations_list) 1394 | self.current_batch_item_index = 0 1395 | if self.total_batch_items == 0: 1396 | self.report({'INFO'}, "No items found for batch backup.") 1397 | self.is_batch_operation = False # Reset 1398 | return {'FINISHED'} 1399 | context.window_manager.modal_handler_add(self) # Add modal handler ONCE for the whole batch 1400 | return self._process_next_batch_item_or_finish(context) 1401 | 1402 | elif self.button_input == 'BATCH_RESTORE': 1403 | # --- Temporarily disable 'Save on Quit' for BATCH_RESTORE operation --- 1404 | prefs_main = context.preferences # Use bpy.context.preferences 1405 | if prefs_main and hasattr(prefs_main, 'use_preferences_save'): 1406 | if prefs_main.use_preferences_save: # Only change if it was True 1407 | prefs_main.use_preferences_save = False 1408 | if pref_instance.debug: 1409 | print(f"DEBUG: OT_BackupManager.execute BATCH_RESTORE: Temporarily disabled 'Save Preferences on Quit' for the batch.") 1410 | elif pref_instance.debug: 1411 | print(f"DEBUG: OT_BackupManager.execute BATCH_RESTORE: Could not access 'use_save_on_quit' to disable it for the batch.") 1412 | # --- End temporary disable --- 1413 | self.is_batch_operation = True 1414 | self.batch_operations_list.clear() 1415 | self.batch_report_lines.clear() 1416 | for version in pref_restore_versions: # Iterate over the list from preferences 1417 | version_name = version[0] 1418 | source_path = os.path.join(pref_instance.backup_path, version_name) 1419 | target_path = os.path.join(os.path.dirname(pref_instance.blender_user_path), version_name) 1420 | self.batch_operations_list.append((source_path, target_path, 'RESTORE', version_name)) 1421 | self.total_batch_items = len(self.batch_operations_list) 1422 | self.current_batch_item_index = 0 1423 | if self.total_batch_items == 0: 1424 | self.report({'INFO'}, "No items found for batch restore.") 1425 | self.is_batch_operation = False # Reset 1426 | return {'FINISHED'} 1427 | context.window_manager.modal_handler_add(self) # Add modal handler ONCE for the whole batch 1428 | return self._process_next_batch_item_or_finish(context) 1429 | 1430 | elif self.button_input == 'DELETE_BACKUP': 1431 | if not pref_instance.advanced_mode: 1432 | target_path = os.path.join(pref_instance.backup_path, str(pref_instance.active_blender_version)).replace("\\", "/") 1433 | else: 1434 | if pref_instance.custom_version_toggle: 1435 | target_path = os.path.join(pref_instance.backup_path, str(pref_instance.custom_version)) 1436 | else: 1437 | target_path = os.path.join(pref_instance.backup_path, pref_instance.restore_versions) 1438 | 1439 | if os.path.exists(target_path): 1440 | try: 1441 | if not prefs().dry_run: 1442 | shutil.rmtree(target_path) 1443 | 1444 | action_verb = "Would delete" if prefs().dry_run else "Deleted" 1445 | report_msg_line1 = f"{action_verb} backup:" 1446 | report_msg_line2 = target_path 1447 | self.report({'INFO'}, f"{report_msg_line1} {report_msg_line2}") 1448 | bpy.app.timers.register(lambda: OT_BackupManager._deferred_show_report_static([report_msg_line1, report_msg_line2], "Delete Backup Report", 'COLORSET_01_VEC'), first_interval=0.01) 1449 | if pref_instance.debug or pref_instance.dry_run: 1450 | print(f"\n{action_verb} Backup: {target_path}") 1451 | 1452 | except OSError as e: 1453 | action_verb = "Failed to (dry run) delete" if pref_instance.dry_run else "Failed to delete" 1454 | error_msg_line1 = f"{action_verb} {target_path}:" 1455 | error_msg_line2 = str(e) 1456 | self.report({'WARNING'}, f"{error_msg_line1} {error_msg_line2}") 1457 | OT_BackupManager.ShowReport_static(message=[error_msg_line1, error_msg_line2], title="Delete Backup Error", icon='WARNING') 1458 | if prefs().debug: # Keep print for debug 1459 | print(f"\n{action_verb} {target_path}: {e}") 1460 | else: 1461 | not_found_msg = f"Not found, nothing to delete: {target_path}" 1462 | self.report({'INFO'}, not_found_msg) 1463 | bpy.app.timers.register(lambda: OT_BackupManager._deferred_show_report_static([f"Not found, nothing to delete:", target_path], "Delete Backup Report", 'INFO'), first_interval=0.01) 1464 | if pref_instance.debug: # Keep print for debug 1465 | print(f"\nBackup to delete not found: {target_path}") 1466 | 1467 | elif self.button_input == 'SEARCH_BACKUP': 1468 | _search_start_sb = None 1469 | if prefs().debug: 1470 | _search_start_sb = datetime.now() 1471 | print(f"DEBUG: execute SEARCH_BACKUP START") 1472 | # Path to the directory containing Blender version folders (e.g., .../Blender/3.6, .../Blender/4.0) 1473 | blender_versions_parent_dir = os.path.dirname(bpy.utils.resource_path(type='USER')) 1474 | 1475 | pref_backup_versions.clear() 1476 | _fv1_start_sb = None 1477 | if prefs().debug: 1478 | _fv1_start_sb = datetime.now() 1479 | print(f"DEBUG: execute SEARCH_BACKUP calling find_versions for blender_versions_parent_dir: {blender_versions_parent_dir}") 1480 | found_backup_versions = find_versions(blender_versions_parent_dir) 1481 | if prefs().debug: 1482 | _fv1_end_sb = datetime.now() 1483 | print(f"DEBUG: (took: {(_fv1_end_sb - _fv1_start_sb).total_seconds():.6f}s) execute SEARCH_BACKUP find_versions for blender_versions_parent_dir DONE") 1484 | pref_backup_versions.extend(found_backup_versions) 1485 | pref_backup_versions.sort(reverse=True) 1486 | 1487 | pref_restore_versions.clear() 1488 | # Combine found versions from backup path and current Blender versions, then make unique 1489 | _fv2_start_sb = None 1490 | if prefs().debug: 1491 | _fv2_start_sb = datetime.now() 1492 | print(f"DEBUG: execute SEARCH_BACKUP calling find_versions for backup_path: {prefs().backup_path}") 1493 | combined_restore_versions = find_versions(prefs().backup_path) + pref_backup_versions 1494 | if prefs().debug: 1495 | _fv2_end_sb = datetime.now() 1496 | print(f"DEBUG: (took: {(_fv2_end_sb - _fv2_start_sb).total_seconds():.6f}s) execute SEARCH_BACKUP find_versions for backup_path DONE") 1497 | # Use dict.fromkeys to preserve order of first appearance if that's desired before sorting 1498 | pref_restore_versions.extend(list(dict.fromkeys(combined_restore_versions))) 1499 | pref_restore_versions.sort(reverse=True) 1500 | if prefs().debug and _search_start_sb: 1501 | _search_end_sb = datetime.now() 1502 | print(f"DEBUG: (took: {(_search_end_sb - _search_start_sb).total_seconds():.6f}s) execute SEARCH_BACKUP END") 1503 | 1504 | elif self.button_input == 'SEARCH_RESTORE': 1505 | _search_start_sr = None 1506 | if prefs().debug: 1507 | _search_start_sr = datetime.now() 1508 | print(f"DEBUG: execute SEARCH_RESTORE START") 1509 | blender_versions_parent_dir = os.path.dirname(bpy.utils.resource_path(type='USER')) 1510 | 1511 | pref_restore_versions.clear() 1512 | _fv1_start_sr = None 1513 | if prefs().debug: 1514 | _fv1_start_sr = datetime.now() 1515 | print(f"DEBUG: execute SEARCH_RESTORE calling find_versions for backup_path: {prefs().backup_path}") 1516 | found_restore_versions = find_versions(prefs().backup_path) 1517 | if prefs().debug: 1518 | _fv1_end_sr = datetime.now() 1519 | print(f"DEBUG: (took: {(_fv1_end_sr - _fv1_start_sr).total_seconds():.6f}s) execute SEARCH_RESTORE find_versions for backup_path DONE") 1520 | pref_restore_versions.extend(found_restore_versions) 1521 | pref_restore_versions.sort(reverse=True) 1522 | 1523 | pref_backup_versions.clear() 1524 | _fv2_start_sr = None 1525 | if prefs().debug: 1526 | _fv2_start_sr = datetime.now() 1527 | print(f"DEBUG: execute SEARCH_RESTORE calling find_versions for blender_versions_parent_dir: {blender_versions_parent_dir}") 1528 | combined_backup_versions = find_versions(blender_versions_parent_dir) + pref_restore_versions 1529 | if prefs().debug: 1530 | _fv2_end_sr = datetime.now() 1531 | print(f"DEBUG: (took: {(_fv2_end_sr - _fv2_start_sr).total_seconds():.6f}s) execute SEARCH_RESTORE find_versions for blender_versions_parent_dir DONE") 1532 | 1533 | if prefs().debug: 1534 | print("Combined backup versions before filtering: ", combined_backup_versions) 1535 | 1536 | # Filter and sort backup versions 1537 | unique_backup_versions = list(dict.fromkeys(combined_backup_versions)) 1538 | valid_backup_versions = [] 1539 | for version_tuple in unique_backup_versions: 1540 | try: 1541 | float(version_tuple[0]) # Check if version name can be a float 1542 | valid_backup_versions.append(version_tuple) 1543 | except ValueError: 1544 | if prefs().debug: 1545 | print(f"Filtered out non-float-like version from backup_versions: {version_tuple[0]}") 1546 | 1547 | pref_backup_versions.extend(valid_backup_versions) 1548 | if prefs().debug: 1549 | print("Final backup_versions list: ", pref_backup_versions) 1550 | pref_backup_versions.sort(reverse=True) 1551 | if prefs().debug and _search_start_sr: 1552 | _search_end_sr = datetime.now() 1553 | print(f"DEBUG: (took: {(_search_end_sr - _search_start_sr).total_seconds():.6f}s) execute SEARCH_RESTORE END") 1554 | 1555 | else: 1556 | OT_BackupManager.ShowReport_static(["Specify a Backup Path"] , "Backup Path missing", 'ERROR') 1557 | return {'FINISHED'} 1558 | 1559 | -------------------------------------------------------------------------------- /preferences.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | 19 | import bpy 20 | import os 21 | from datetime import datetime 22 | import socket 23 | from bpy.types import AddonPreferences 24 | from bpy.props import StringProperty, EnumProperty, BoolProperty, FloatProperty 25 | from bpy.props import FloatVectorProperty # Added for color property 26 | from . import core # To reference OT_BackupManagerWindow.bl_idname 27 | 28 | def get_paths_for_details(prefs_instance): 29 | """ 30 | Collects all unique directory paths that might need age/size details displayed, 31 | based on the current addon preference settings. 32 | """ 33 | paths = set() 34 | p = prefs_instance 35 | 36 | if not p.backup_path: # If no backup path, many other paths are invalid 37 | return [] 38 | 39 | # Paths from draw_backup logic 40 | if not p.advanced_mode: 41 | if p.blender_user_path: paths.add(p.blender_user_path) 42 | if p.active_blender_version: # Ensure active_blender_version is not empty 43 | paths.add(os.path.join(p.backup_path, str(p.active_blender_version))) 44 | elif p.advanced_mode: # advanced_mode is True 45 | base_user_path_dir = os.path.dirname(p.blender_user_path) if p.blender_user_path else None 46 | if base_user_path_dir and p.backup_versions: # p.backup_versions is the selected string 47 | paths.add(os.path.join(base_user_path_dir, p.backup_versions)) 48 | 49 | if p.custom_version_toggle and p.custom_version: # p.custom_version is a string 50 | paths.add(os.path.join(p.backup_path, str(p.custom_version))) 51 | elif p.restore_versions: # Not custom_version_toggle, p.restore_versions is selected string 52 | paths.add(os.path.join(p.backup_path, p.restore_versions)) 53 | 54 | # Paths from draw_restore logic (many will be duplicates and handled by the set) 55 | if not p.advanced_mode: 56 | if p.active_blender_version: # Ensure active_blender_version is not empty 57 | paths.add(os.path.join(p.backup_path, str(p.active_blender_version))) 58 | if p.blender_user_path: paths.add(p.blender_user_path) 59 | elif p.advanced_mode: # advanced_mode is True 60 | if p.restore_versions: # p.restore_versions is selected string 61 | paths.add(os.path.join(p.backup_path, p.restore_versions)) 62 | base_user_path_dir = os.path.dirname(p.blender_user_path) if p.blender_user_path else None 63 | if base_user_path_dir and p.backup_versions: # p.backup_versions is selected string 64 | paths.add(os.path.join(base_user_path_dir, p.backup_versions)) 65 | 66 | final_paths = list(path for path in paths if path) # Filter out only None or empty strings 67 | if prefs_instance.debug: 68 | print(f"DEBUG: get_paths_for_details collected {len(final_paths)} relevant paths: {final_paths if len(final_paths) < 5 else '[Too many to list, see raw for full list]'}") 69 | return final_paths 70 | 71 | def get_default_base_temp_dir(): 72 | """Safely determines a base temporary directory for addon defaults.""" 73 | temp_dir_path = "" 74 | try: 75 | # Try to access bpy.context and its attributes safely 76 | if bpy.context and hasattr(bpy.context, 'preferences') and \ 77 | hasattr(bpy.context.preferences, 'filepaths') and \ 78 | bpy.context.preferences.filepaths.temporary_directory: 79 | temp_dir_path = bpy.context.preferences.filepaths.temporary_directory 80 | else: 81 | # Fallback if user-specified temp path isn't available or context is limited 82 | temp_dir_path = bpy.app.tempdir 83 | except (RuntimeError, AttributeError, ReferenceError): 84 | # Broader fallback if bpy.context is unstable or bpy.app.tempdir fails 85 | try: 86 | temp_dir_path = bpy.app.tempdir 87 | except AttributeError: # Absolute fallback if bpy.app.tempdir also fails 88 | temp_dir_path = os.path.join(os.path.expanduser("~"), "blender_temp_fallback") 89 | os.makedirs(temp_dir_path, exist_ok=True) # Ensure fallback path exists 90 | return temp_dir_path 91 | 92 | def _calculate_path_age_str(path_to_scan): 93 | try: 94 | if not path_to_scan or not os.path.isdir(path_to_scan): return "Last change: N/A" # Should be pre-filtered by get_paths_for_details 95 | files = [os.path.join(dp, f) for dp, dn, filenames in os.walk(path_to_scan) for f in filenames] 96 | if not files: return "Last change: no data (empty)" 97 | latest_file = max(files, key=os.path.getmtime) 98 | backup_age = str(datetime.now() - datetime.fromtimestamp(os.path.getmtime(latest_file))).split('.')[0] 99 | return f"Last change: {backup_age}" 100 | except Exception: return "Last change: error" 101 | 102 | def _calculate_path_size_str(path_to_scan): 103 | try: 104 | if not path_to_scan or not os.path.isdir(path_to_scan): return "Size: N/A" # Should be pre-filtered 105 | size = sum(os.path.getsize(os.path.join(dp, f)) for dp, dn, filenames in os.walk(path_to_scan) for f in filenames) 106 | return ( 107 | f"Size: {str(round(size * 1e-06, 2))} MB (" 108 | + "{:,}".format(size) 109 | + " bytes)" 110 | ) 111 | except Exception: return "Size: error" 112 | 113 | class OT_OpenPathInExplorer(bpy.types.Operator): 114 | """Operator to open a given path in the system's file explorer.""" 115 | bl_idname = "bm.open_path_in_explorer" 116 | bl_label = "Open Folder" 117 | bl_description = "Open the specified path in the system file explorer" 118 | bl_options = {'INTERNAL'} # Hide from F3 operator search 119 | 120 | path_to_open: StringProperty( 121 | name="Path", 122 | description="The file or directory path to open" 123 | ) 124 | 125 | def execute(self, context): 126 | if not self.path_to_open: 127 | self.report({'WARNING'}, "No path specified to open.") 128 | return {'CANCELLED'} 129 | 130 | normalized_path = os.path.normpath(self.path_to_open) 131 | 132 | if not os.path.exists(normalized_path): 133 | self.report({'WARNING'}, f"Path does not exist: {normalized_path}") 134 | return {'CANCELLED'} 135 | 136 | try: 137 | # If it's a file, open its containing directory. Otherwise, open the path directly (assuming it's a directory). 138 | target_to_open_in_explorer = os.path.dirname(normalized_path) if os.path.isfile(normalized_path) else normalized_path 139 | 140 | if not os.path.isdir(target_to_open_in_explorer): # Final check 141 | self.report({'WARNING'}, f"Cannot open: Not a valid directory: {target_to_open_in_explorer}") 142 | return {'CANCELLED'} 143 | 144 | bpy.ops.wm.path_open(filepath=target_to_open_in_explorer) 145 | return {'FINISHED'} 146 | except Exception as e: 147 | self.report({'ERROR'}, f"Could not open path '{normalized_path}': {e}") 148 | return {'CANCELLED'} 149 | 150 | class BM_Preferences(AddonPreferences): 151 | bl_idname = __package__ 152 | this_version = str(bpy.app.version[0]) + '.' + str(bpy.app.version[1]) 153 | 154 | _age_cache = {} 155 | _size_cache = {} 156 | _initial_scan_done = False # Flag to track if the initial version scan has run 157 | FILES_PER_TICK_MODAL_OP: int = 10 # Process this many files per timer event in OT_BackupManager 158 | 159 | initial_version = f'{str(bpy.app.version[0])}.{str(bpy.app.version[1])}' 160 | backup_version_list = [(initial_version, initial_version, '')] # Standardize to 3-element tuple 161 | restore_version_list = [(initial_version, initial_version, '')] # Standardize to 3-element tuple 162 | 163 | def _update_backup_path_and_versions(self, context): 164 | """ 165 | Central update handler for backup_path and related UI settings. 166 | Ensures backup_path is consistent with use_system_id, then refreshes version lists and details. 167 | """ 168 | if self.debug: 169 | print("\n" + "-"*10 + f" _update_backup_path_and_versions (NEW FRAME) for tabs: {self.tabs} " + "-"*10 + "\n") 170 | _start_time_main_update = datetime.now() 171 | print(f"DEBUG: _update_backup_path_and_versions START. Current backup_path: '{self.backup_path}', use_system_id: {self.use_system_id}") 172 | 173 | # Step 1: Ensure backup_path consistency with use_system_id 174 | _current_bp_val = self.backup_path 175 | _made_change_to_bp_for_consistency = False 176 | 177 | if self.system_id and _current_bp_val: # Only proceed if system_id and current path are valid 178 | normalized_current_path = os.path.normpath(_current_bp_val) 179 | # Ensure system_id is treated as a single, clean directory name 180 | clean_system_id_name = self.system_id.strip(os.sep) 181 | system_id_suffix_to_check = os.sep + clean_system_id_name 182 | 183 | path_ends_with_system_id = normalized_current_path.endswith(system_id_suffix_to_check) 184 | 185 | if self.use_system_id: 186 | if not path_ends_with_system_id and clean_system_id_name: # Append if not present and system_id is non-empty 187 | new_path = os.path.join(_current_bp_val, clean_system_id_name) 188 | if os.path.normpath(new_path) != normalized_current_path: 189 | if self.debug: print(f"DEBUG: _update_backup_path_and_versions: Appending system_id. Path changing from '{_current_bp_val}' to '{new_path}'") 190 | self.backup_path = new_path 191 | _made_change_to_bp_for_consistency = True 192 | else: # use_system_id is False 193 | if path_ends_with_system_id: # Strip if present 194 | base_path = normalized_current_path[:-len(system_id_suffix_to_check)] 195 | if base_path and os.path.normpath(base_path) != normalized_current_path: # Ensure base_path is not empty and a real change occurs 196 | if self.debug: print(f"DEBUG: _update_backup_path_and_versions: Stripping system_id. Path changing from '{_current_bp_val}' to '{base_path}'") 197 | self.backup_path = base_path 198 | _made_change_to_bp_for_consistency = True 199 | 200 | if _made_change_to_bp_for_consistency: 201 | if self.debug: print(f"DEBUG: _update_backup_path_and_versions: self.backup_path was modified for consistency. Exiting to re-enter update cycle. New path: '{self.backup_path}'") 202 | return # Exit because self.backup_path was changed, this update function will run again. 203 | 204 | # Step 2: Original logic (clear caches, search versions, update path details) 205 | # This part only runs if no early return happened due to consistency changes. 206 | if self.debug: 207 | # Clear caches when version lists are being updated 208 | BM_Preferences._age_cache.clear() 209 | print("DEBUG: _update_backup_path_and_versions: Cleared _age_cache.") 210 | BM_Preferences._size_cache.clear() 211 | print("DEBUG: _update_backup_path_and_versions: Cleared _size_cache.") 212 | 213 | if self.debug: 214 | _call_time_search_op = datetime.now() 215 | print(f"DEBUG: _update_backup_path_and_versions: CALLING bpy.ops.bm.run_backup_manager with SEARCH_{self.tabs}") 216 | try: 217 | bpy.ops.bm.run_backup_manager(button_input=f'SEARCH_{self.tabs}') 218 | except Exception as e: 219 | print(f"ERROR: Backup Manager: Error calling bpy.ops.bm.run_backup_manager in _update_backup_path_and_versions (likely during script reload): {e}") 220 | return # Stop further processing in this update if the op call failed 221 | if self.debug: 222 | _end_time_search_op = datetime.now() 223 | print(f"DEBUG: (took: {(_end_time_search_op - _call_time_search_op).total_seconds():.6f}s) _update_backup_path_and_versions: FINISHED bpy.ops.bm.run_backup_manager.") 224 | 225 | BM_Preferences._initial_scan_done = True 226 | 227 | if self.show_path_details: 228 | if self.debug: 229 | print("DEBUG: _update_backup_path_and_versions: show_path_details is True, recalculating details.") 230 | paths = get_paths_for_details(self) 231 | if self._update_path_details_for_paths(paths): 232 | if context and hasattr(context, 'area') and context.area: 233 | context.area.tag_redraw() 234 | elif self.debug: 235 | print("DEBUG: _update_backup_path_and_versions: context or context.area not available for tag_redraw after detail update.") 236 | elif self.debug: # This else corresponds to "if self.show_path_details:" 237 | print("DEBUG: _update_backup_path_and_versions: show_path_details is False, not recalculating details.") 238 | 239 | if self.debug and _start_time_main_update: 240 | _end_time_main_update = datetime.now() 241 | print(f"DEBUG: (Total took: {(_end_time_main_update - _start_time_main_update).total_seconds():.6f}s) _update_backup_path_and_versions END") 242 | 243 | # Calculate the initial default backup path safely ONCE when the class is defined. 244 | # This function call happens during module import / class definition. 245 | _initial_default_backup_path = os.path.join(get_default_base_temp_dir(), '!backupmanager') 246 | 247 | def update_system_id(self, context): 248 | """Updates the backup_path when use_system_id is toggled.""" 249 | if self.debug: print(f"DEBUG: update_system_id (for use_system_id toggle) CALLED. use_system_id is now {self.use_system_id}. Current backup_path: '{self.backup_path}'") 250 | 251 | current_path = self.backup_path 252 | if not current_path or not self.system_id: # Safety check 253 | if self.debug: print("DEBUG: update_system_id: Current path or system_id is empty, cannot modify path.") 254 | # Trigger the main update anyway to refresh lists based on current state 255 | self._update_backup_path_and_versions(context) 256 | return 257 | 258 | normalized_current_path = os.path.normpath(current_path) 259 | clean_system_id_name = self.system_id.strip(os.sep) 260 | system_id_suffix_to_check = os.sep + clean_system_id_name 261 | path_ends_with_system_id = normalized_current_path.endswith(system_id_suffix_to_check) 262 | 263 | new_path_candidate = None 264 | 265 | if self.use_system_id: # User wants system_id in path 266 | if not path_ends_with_system_id and clean_system_id_name: 267 | new_path_candidate = os.path.join(current_path, clean_system_id_name) 268 | else: # User does NOT want system_id in path 269 | if path_ends_with_system_id: 270 | potential_base = normalized_current_path[:-len(system_id_suffix_to_check)] 271 | if potential_base: 272 | new_path_candidate = potential_base 273 | 274 | if new_path_candidate and os.path.normpath(new_path_candidate) != normalized_current_path: 275 | if self.debug: print(f"DEBUG: update_system_id: Changing backup_path from '{current_path}' to '{new_path_candidate}'") 276 | self.backup_path = new_path_candidate # This assignment will trigger _update_backup_path_and_versions 277 | elif self.debug: 278 | print(f"DEBUG: update_system_id: backup_path ('{current_path}') already consistent or no change needed. Triggering full update.") 279 | # Even if path doesn't change here, lists might need refresh based on the toggle. 280 | # The main update function will handle consistency again, then refresh lists. 281 | self._update_backup_path_and_versions(context) 282 | 283 | backup_path: StringProperty(name="Backup Path", 284 | description="Backup Location", 285 | subtype='DIR_PATH', 286 | default=_initial_default_backup_path, 287 | update=_update_backup_path_and_versions) 288 | 289 | blender_user_path: StringProperty(default=bpy.utils.resource_path(type='USER')) 290 | 291 | preferences_tabs = [("BACKUP", "Backup Options", ""), 292 | ("RESTORE", "Restore Options", "")] 293 | 294 | tabs: EnumProperty(name="Tabs", 295 | items=preferences_tabs, 296 | default="BACKUP", 297 | update=_update_backup_path_and_versions) 298 | 299 | config_path: StringProperty(name="config_path", 300 | description="config_path", 301 | subtype='DIR_PATH', 302 | default=bpy.utils.user_resource('CONFIG')) #Resource type in [‘DATAFILES’, ‘CONFIG’, ‘SCRIPTS’, ‘AUTOSAVE’]. 303 | 304 | system_id: StringProperty(name="System ID", 305 | description="Current Computer ID, used to create unique backup paths", 306 | subtype='NONE', 307 | default=str(socket.getfqdn())) 308 | 309 | use_system_id: BoolProperty(name="Organize Backups by Computer", 310 | description="If enabled, appends this computer's unique network name (e.g., 'MyComputer.domain.com') " \ 311 | "as a subfolder to the 'Main Backup Location'. " \ 312 | "This prevents backups from different computers from overwriting each other when using a shared backup drive.", 313 | update=update_system_id, 314 | default=True) # default = True 315 | 316 | debug: BoolProperty(name="Debug Output", 317 | description="Enable debug logging", 318 | # update=update_system_id, # Debug toggle should not typically change system ID path logic 319 | default=False) # default = False 320 | 321 | active_blender_version: StringProperty(name="Current Blender Version", 322 | description="Current Blender Version", 323 | subtype='NONE', 324 | default=this_version) 325 | dry_run: BoolProperty(name="Dry Run", 326 | description="Run code without modifying any files on the drive." 327 | "NOTE: this will not create or restore any backups!", 328 | default=False) # default = False 329 | 330 | def _update_path_details_for_paths(self, paths_to_update): 331 | """Helper to calculate and cache details for a list of paths.""" 332 | if not self.show_path_details: 333 | return False # Do nothing if details are not shown 334 | 335 | cache_updated = False 336 | # Caches are now class attributes, no need for hasattr check here for initialization 337 | 338 | for path in paths_to_update: 339 | if self.debug: print(f"DEBUG: _update_path_details_for_paths: Processing '{path}'") 340 | new_age_text = _calculate_path_age_str(path) 341 | if BM_Preferences._age_cache.get(path) != new_age_text: 342 | BM_Preferences._age_cache[path] = new_age_text 343 | cache_updated = True 344 | if self.debug: print(f"DEBUG: _update_path_details_for_paths: Cached new age for '{path}'") 345 | 346 | new_size_text = _calculate_path_size_str(path) 347 | if BM_Preferences._size_cache.get(path) != new_size_text: 348 | BM_Preferences._size_cache[path] = new_size_text 349 | cache_updated = True 350 | if self.debug: print(f"DEBUG: _update_path_details_for_paths: Cached new size for '{path}'") 351 | 352 | if self.debug and not cache_updated and paths_to_update: 353 | print(f"DEBUG: _update_path_details_for_paths: No cache changes for paths: {paths_to_update if len(paths_to_update) < 5 else '[Multiple paths, no changes]'}") 354 | return cache_updated 355 | 356 | def _on_show_path_details_changed(self, context): 357 | """Update callback for show_path_details.""" 358 | if self.debug: 359 | print(f"DEBUG: _on_show_path_details_changed called. self.show_path_details = {self.show_path_details}") 360 | if self.show_path_details: 361 | if self.debug: print("DEBUG: show_path_details enabled. Calculating details for current view.") 362 | paths = get_paths_for_details(self) 363 | # Path list already printed by get_paths_for_details if debug is on 364 | # if self.debug: print(f"DEBUG: _on_show_path_details_changed: paths_to_update = {paths}") 365 | if self._update_path_details_for_paths(paths): 366 | if context and hasattr(context, 'area') and context.area: 367 | context.area.tag_redraw() 368 | elif self.debug: 369 | print("DEBUG: _on_show_path_details_changed: context or context.area not available for tag_redraw.") 370 | elif self.debug: 371 | print("DEBUG: _on_show_path_details_changed: show_path_details is now False.") 372 | 373 | def _on_version_or_custom_changed(self, context): 374 | """Update callback for version enums and custom_version string.""" 375 | if self.debug: 376 | print(f"DEBUG: _on_version_or_custom_changed TRIGGERED. self.show_path_details = {self.show_path_details}") 377 | print(f"DEBUG: Current selections: backup_versions='{self.backup_versions}', restore_versions='{self.restore_versions}', custom_version='{self.custom_version}', custom_toggle={self.custom_version_toggle}") 378 | 379 | if self.show_path_details: 380 | if self.debug: print("DEBUG: Version selection or custom version changed. Recalculating details for current view.") 381 | paths = get_paths_for_details(self) # Re-evaluate all relevant paths 382 | # Path list already printed by get_paths_for_details if debug is on 383 | # if self.debug: print(f"DEBUG: _on_version_or_custom_changed: paths_to_update = {paths}") 384 | 385 | if self._update_path_details_for_paths(paths): 386 | if self.debug: print("DEBUG: _on_version_or_custom_changed: Cache updated, tagging for redraw.") 387 | if context and hasattr(context, 'area') and context.area: 388 | context.area.tag_redraw() 389 | elif self.debug: 390 | print("DEBUG: _on_version_or_custom_changed: context or context.area not available for tag_redraw.") 391 | elif self.debug: 392 | print("DEBUG: _on_version_or_custom_changed: Cache was not updated by _update_path_details_for_paths.") 393 | elif self.debug: 394 | print("DEBUG: _on_version_or_custom_changed: show_path_details is False, not calculating details.") 395 | 396 | 397 | show_path_details: BoolProperty(name="Show Path Details", 398 | description="Display last change date and size for backup/restore paths. Calculated on demand.", 399 | default=True, 400 | update=_on_show_path_details_changed) 401 | 402 | show_operation_progress: BoolProperty( 403 | default=False, 404 | options={'SKIP_SAVE'} # Internal: Controls visibility of progress UI, should not persist. 405 | ) 406 | operation_progress_value: FloatProperty( 407 | default=0.0, 408 | min=0.0, 409 | max=100.0, # Back to 0-100 range 410 | subtype='PERCENTAGE', # Back to PERCENTAGE subtype 411 | options={'SKIP_SAVE'} # Internal: Progress value, should not persist. 412 | ) 413 | operation_progress_message: StringProperty( 414 | default="Waiting...", 415 | options={'SKIP_SAVE'} # Internal: Progress message, should not persist. 416 | ) 417 | abort_operation_requested: BoolProperty( 418 | default=False, 419 | options={'SKIP_SAVE'} # Internal: Flag to signal abort from UI, should not persist. 420 | ) 421 | 422 | advanced_mode: BoolProperty(name="Advanced", 423 | description="Advanced backup and restore options", 424 | update=_update_backup_path_and_versions, 425 | default=True) # default = True 426 | 427 | expand_version_selection: BoolProperty(name="Expand Versions", 428 | description="Switch between dropdown and expanded version layout", 429 | update=_update_backup_path_and_versions, 430 | default=True) # default = True 431 | 432 | custom_version: StringProperty(name="Custom Version", 433 | description="Custom version folder", 434 | subtype='NONE', 435 | default='custom', 436 | update=_on_version_or_custom_changed) # This specific update is fine for path details 437 | 438 | # BACKUP (custom_version_toggle was defined twice, keeping this one as it's grouped with other backup options) 439 | custom_version_toggle: BoolProperty(name="Custom Version", 440 | description="Set your custom backup version", 441 | default=False, # default = False 442 | update=_update_backup_path_and_versions, 443 | ) 444 | 445 | clean_path: BoolProperty(name="Clean Backup", 446 | description="delete before backup", 447 | default=False) # default = False 448 | 449 | def populate_backuplist(self, context): 450 | #if hasattr(self, 'debug') and self.debug: # Check if self has debug, might not always if context is weird 451 | #print(f"DEBUG: populate_backuplist CALLED. Returning BM_Preferences.backup_version_list (len={len(BM_Preferences.backup_version_list)}): {BM_Preferences.backup_version_list}") 452 | current_list = BM_Preferences.backup_version_list 453 | if not isinstance(current_list, list) or not all(isinstance(item, tuple) and len(item) == 3 for item in current_list if item): # Check list integrity 454 | print("ERROR: Backup Manager: BM_Preferences.backup_version_list is malformed in populate_backuplist. Returning default.") 455 | return [(BM_Preferences.initial_version, BM_Preferences.initial_version, "Default version")] 456 | if not current_list: # If the list is empty 457 | return [("(NONE)", "No Versions Found", "Perform a search or check backup path")] 458 | # Ensure all items are 3-element tuples 459 | # The list should already contain 3-element tuples from find_versions 460 | return current_list 461 | 462 | backup_versions: EnumProperty(items=populate_backuplist, 463 | name="Backup", 464 | description="Choose the version to backup", 465 | update=_on_version_or_custom_changed) # This specific update is fine for path details 466 | 467 | backup_cache: BoolProperty(name="cache", description="backup_cache", default=False) # default = False 468 | backup_bookmarks: BoolProperty(name="bookmarks", description="backup_bookmarks", default=True) # default = True 469 | backup_recentfiles: BoolProperty(name="recentfiles", description="backup_recentfiles", default=True) # default = True 470 | backup_startup_blend: BoolProperty( name="startup.blend", description="backup_startup_blend", default=True) # default = True 471 | backup_userpref_blend: BoolProperty(name="userpref.blend", description="backup_userpref_blend", default=True) # default = True 472 | backup_workspaces_blend: BoolProperty(name="workspaces.blend", description="backup_workspaces_blend", default=True) # default = True 473 | backup_datafile: BoolProperty( name="datafile", description="backup_datafile", default=True) # default = True 474 | backup_addons: BoolProperty(name="addons", description="backup_addons", default=True) # default = True 475 | backup_extensions: BoolProperty(name="extensions", description="backup_extensions", default=True) # default = True 476 | backup_presets: BoolProperty(name="presets", description="backup_presets", default=True) # default = True 477 | 478 | 479 | # RESTORE 480 | def populate_restorelist(self, context): 481 | #if hasattr(self, 'debug') and self.debug: 482 | #print(f"DEBUG: populate_restorelist CALLED. Returning BM_Preferences.restore_version_list (len={len(BM_Preferences.restore_version_list)}): {BM_Preferences.restore_version_list}") 483 | current_list = BM_Preferences.restore_version_list 484 | if not isinstance(current_list, list) or not all(isinstance(item, tuple) and len(item) == 3 for item in current_list if item): # Check list integrity 485 | print("ERROR: Backup Manager: BM_Preferences.restore_version_list is malformed in populate_restorelist. Returning default.") 486 | return [(BM_Preferences.initial_version, BM_Preferences.initial_version, "Default version")] 487 | if not current_list: # If the list is empty 488 | return [("(NONE)", "No Versions Found", "Perform a search or check backup path")] 489 | # Ensure all items are 3-element tuples 490 | # The list should already contain 3-element tuples from find_versions 491 | return current_list 492 | 493 | restore_versions: EnumProperty(items=populate_restorelist, 494 | name="Restore", 495 | description="Choose the version to Resotre", 496 | update=_on_version_or_custom_changed) # This specific update is fine for path details 497 | 498 | restore_cache: BoolProperty(name="cache", description="restore_cache", default=False) # default = False 499 | restore_bookmarks: BoolProperty(name="bookmarks", description="restore_bookmarks", default=True) # default = True 500 | restore_recentfiles: BoolProperty(name="recentfiles", description="restore_recentfiles", default=True) # default = True 501 | restore_startup_blend: BoolProperty(name="startup.blend", description="restore_startup_blend", default=True) # default = True 502 | restore_userpref_blend: BoolProperty(name="userpref.blend", description="restore_userpref_blend", default=True) # default = True 503 | restore_workspaces_blend: BoolProperty(name="workspaces.blend", description="restore_workspaces_blend", default=True) # default = True 504 | restore_datafile: BoolProperty(name="datafile", description="restore_datafile", default=True) # default = True 505 | restore_addons: BoolProperty(name="addons", description="restore_addons", default=True) # default = True 506 | restore_extensions: BoolProperty(name="extensions", description="restore_extensions", default=True) # default = True 507 | restore_presets: BoolProperty(name="presets", description="restore_presets", default=True) # default = True 508 | 509 | ignore_files: StringProperty(name="Ignore Files", 510 | description="Ignore files from being backed up or restored", 511 | subtype='FILE_NAME', 512 | default='desktop.ini') 513 | 514 | # Progress Bar Color Customization 515 | override_progress_bar_color: BoolProperty( 516 | name="Override Progress Bar Color", 517 | description="Enable to use a custom color for the addon's progress bar", 518 | default=False) 519 | custom_progress_bar_color: FloatVectorProperty( 520 | name="Custom Progress Bar Color", 521 | description="Color for the addon's progress bar when override is enabled", 522 | subtype='COLOR', size=4, default=(0.2, 0.8, 0.2, 1.0), # Default to a nice green (RGBA) 523 | min=0.0, max=1.0) 524 | 525 | # DRAW Preferences 526 | def draw(self, context): 527 | layout = self.layout 528 | layout.label(text="Backup Manager operations are now handled in a dedicated window.") 529 | layout.operator(core.OT_BackupManagerWindow.bl_idname, text="Open Backup Manager Window", icon='DISK_DRIVE') 530 | 531 | layout.separator() 532 | 533 | # Box for path settings and appearance 534 | box_settings = layout.box() 535 | col_settings = box_settings.column(align=True) 536 | 537 | # Main Backup Location 538 | col_settings.label(text="Storage Location:") 539 | row_backup_path = col_settings.row(align=True) 540 | row_backup_path.prop(self, "backup_path", text="Main Backup Location") 541 | op_backup_loc = row_backup_path.operator(OT_OpenPathInExplorer.bl_idname, icon='FILEBROWSER', text="") 542 | op_backup_loc.path_to_open = self.backup_path 543 | 544 | if self.debug: # Only show system paths if debug is enabled 545 | col_settings.separator() 546 | 547 | # Blender System Paths (Read-Only) 548 | col_settings.label(text="Blender System Paths (Read-Only - Debug):") 549 | 550 | # Blender Installation Path 551 | blender_install_path = os.path.dirname(bpy.app.binary_path) 552 | row_install = col_settings.row(align=True) 553 | row_install.label(text="Installation Path:") 554 | row_install.label(text=blender_install_path) 555 | op_install = row_install.operator(OT_OpenPathInExplorer.bl_idname, icon='FILEBROWSER', text="") 556 | op_install.path_to_open = blender_install_path 557 | 558 | # User Version Folder Path 559 | row_user_version_folder = col_settings.row(align=True) 560 | row_user_version_folder.label(text="User Version Folder:") 561 | row_user_version_folder.label(text=self.blender_user_path) 562 | op_user_version_folder = row_user_version_folder.operator(OT_OpenPathInExplorer.bl_idname, icon='FILEBROWSER', text="") 563 | op_user_version_folder.path_to_open = self.blender_user_path 564 | 565 | # User Config Subfolder Path (e.g., .../VERSION/config) 566 | row_config_subfolder = col_settings.row(align=True) 567 | row_config_subfolder.label(text="User Config Subfolder:") 568 | row_config_subfolder.label(text=self.config_path) 569 | op_config_subfolder = row_config_subfolder.operator(OT_OpenPathInExplorer.bl_idname, icon='FILEBROWSER', text="") 570 | op_config_subfolder.path_to_open = self.config_path 571 | 572 | col_settings.separator() 573 | col_settings.label(text="Progress Bar Appearance:") 574 | row_override_color = col_settings.row(align=True) 575 | row_override_color.prop(self, "override_progress_bar_color", text="Override Color", icon='COLOR') 576 | if self.override_progress_bar_color: 577 | row_custom_color = col_settings.row(align=True) 578 | row_custom_color.prop(self, "custom_progress_bar_color", text="") 579 | 580 | def draw_backup_age(self, col, path): 581 | # Access class attribute 582 | display_text = BM_Preferences._age_cache.get(path) 583 | if display_text is None: # Not yet calculated by timer or path is new 584 | display_text = "Last change: Calculating..." 585 | if self.debug: print(f"DEBUG: draw_backup_age: No cache for '{path}', displaying 'Calculating...'") 586 | elif self.debug: 587 | print(f"DEBUG: draw_backup_age: Using cached value for '{path}': {display_text}") 588 | col.label(text=display_text) 589 | 590 | 591 | def draw_backup_size(self, col, path): 592 | # Access class attribute 593 | display_text = BM_Preferences._size_cache.get(path) 594 | if display_text is None: # Not yet calculated by timer or path is new 595 | display_text = "Size: Calculating..." 596 | if self.debug: print(f"DEBUG: draw_backup_size: No cache for '{path}', displaying 'Calculating...'") 597 | elif self.debug: 598 | print(f"DEBUG: draw_backup_size: Using cached value for '{path}': {display_text}") 599 | col.label(text=display_text) 600 | 601 | 602 | def draw_backup(self, box): 603 | 604 | row = box.row() 605 | box1 = row.box() 606 | col = box1.column() 607 | if not self.advanced_mode: 608 | path = self.blender_user_path 609 | col.label(text = "Backup From: " + str(self.active_blender_version), icon='COLORSET_03_VEC') 610 | col.label(text = path) 611 | if self.show_path_details: 612 | self.draw_backup_age(col, path) 613 | self.draw_backup_size(col, path) 614 | 615 | box = row.box() 616 | col = box.column() 617 | path = os.path.join(self.backup_path, str(self.active_blender_version)) 618 | col.label(text = "Backup To: " + str(self.active_blender_version), icon='COLORSET_04_VEC') 619 | col.label(text = path) 620 | if self.show_path_details: 621 | self.draw_backup_age(col, path) 622 | self.draw_backup_size(col, path) 623 | 624 | elif self.advanced_mode: 625 | if self.custom_version_toggle: 626 | path = os.path.join(os.path.dirname(self.blender_user_path), self.backup_versions) 627 | col.label(text = "Backup From: " + self.backup_versions, icon='COLORSET_03_VEC') 628 | col.label(text = path) 629 | if self.show_path_details: 630 | self.draw_backup_age(col, path) 631 | self.draw_backup_size(col, path) 632 | 633 | box2 = row.box() 634 | col = box2.column() 635 | path = os.path.join(self.backup_path, str(self.custom_version)) 636 | col.label(text = "Backup To: " + str(self.custom_version), icon='COLORSET_04_VEC') 637 | col.label(text = path) 638 | if self.show_path_details: 639 | self.draw_backup_age(col, path) 640 | self.draw_backup_size(col, path) 641 | 642 | else: 643 | path = os.path.join(os.path.dirname(self.blender_user_path), self.backup_versions) 644 | col.label(text = "Backup From: " + self.backup_versions, icon='COLORSET_03_VEC') 645 | col.label(text = path) 646 | if self.show_path_details: 647 | self.draw_backup_age(col, path) 648 | self.draw_backup_size(col, path) 649 | 650 | box2 = row.box() 651 | col = box2.column() 652 | path = os.path.join(self.backup_path, self.restore_versions) 653 | col.label(text = "Backup To: " + self.restore_versions, icon='COLORSET_04_VEC') 654 | col.label(text = path) 655 | if self.show_path_details: 656 | self.draw_backup_age(col, path) 657 | self.draw_backup_size(col, path) 658 | 659 | # Advanced options 660 | col = box1.column() 661 | col.scale_x = 0.8 662 | col.prop(self, 'backup_versions', text='Backup From', expand = self.expand_version_selection) 663 | 664 | col = box2.column() 665 | if self.custom_version_toggle: 666 | col.scale_x = 0.8 667 | col.prop(self, 'custom_version') 668 | else: 669 | col.scale_x = 0.8 670 | col.prop(self, 'restore_versions', text='Backup To', expand = self.expand_version_selection) 671 | 672 | self.draw_selection(box) 673 | 674 | col = row.column() 675 | col.scale_x = 0.8 676 | col.operator("bm.run_backup_manager", text="Backup Selected", icon='COLORSET_03_VEC').button_input = 'BACKUP' 677 | if self.advanced_mode: 678 | col.operator("bm.run_backup_manager", text="Backup All", icon='COLORSET_03_VEC').button_input = 'BATCH_BACKUP' 679 | col.separator(factor=1.0) 680 | col.prop(self, 'dry_run') 681 | col.prop(self, 'clean_path') 682 | col.prop(self, 'advanced_mode') 683 | if self.advanced_mode: 684 | col.prop(self, 'custom_version_toggle') 685 | col.prop(self, 'expand_version_selection') 686 | col.separator(factor=1.0) 687 | col.operator("bm.run_backup_manager", text="Delete Backup", icon='COLORSET_01_VEC').button_input = 'DELETE_BACKUP' 688 | 689 | 690 | def draw_restore(self, box): 691 | row = box.row() 692 | box1 = row.box() 693 | col = box1.column() 694 | if not self.advanced_mode: 695 | path = os.path.join(self.backup_path, str(self.active_blender_version)) 696 | col.label(text = f"Restore From: {self.active_blender_version}", icon='COLORSET_04_VEC') 697 | col.label(text = path) 698 | if self.show_path_details: 699 | self.draw_backup_age(col, path) 700 | self.draw_backup_size(col, path) 701 | 702 | box = row.box() 703 | col = box.column() 704 | path = self.blender_user_path 705 | col.label(text = "Restore To: " + str(self.active_blender_version), icon='COLORSET_03_VEC') 706 | col.label(text = path) 707 | if self.show_path_details: 708 | self.draw_backup_age(col, path) 709 | self.draw_backup_size(col, path) 710 | 711 | else: 712 | path = os.path.join(self.backup_path, self.restore_versions) 713 | col.label(text = "Restore From: " + self.restore_versions, icon='COLORSET_04_VEC') 714 | col.label(text = path) 715 | if self.show_path_details: 716 | self.draw_backup_age(col, path) 717 | self.draw_backup_size(col, path) 718 | 719 | box2 = row.box() 720 | col = box2.column() 721 | path = os.path.join(os.path.dirname(self.blender_user_path), self.backup_versions) 722 | col.label(text = "Restore To: " + self.backup_versions, icon='COLORSET_03_VEC') 723 | col.label(text = path) 724 | if self.show_path_details: 725 | self.draw_backup_age(col, path) 726 | self.draw_backup_size(col, path) 727 | 728 | # Advanced options 729 | col = box1.column() 730 | col.scale_x = 0.8 731 | col.prop(self, 'restore_versions', text='Restore From', expand = self.expand_version_selection) 732 | 733 | col = box2.column() 734 | col.scale_x = 0.8 735 | col.prop(self, 'backup_versions', text='Restore To', expand = self.expand_version_selection) 736 | 737 | self.draw_selection(box) 738 | 739 | col = row.column() 740 | col.scale_x = 0.8 741 | col.operator("bm.run_backup_manager", text="Restore Selected", icon='COLORSET_04_VEC').button_input = 'RESTORE' 742 | if self.advanced_mode: 743 | col.operator("bm.run_backup_manager", text="Restore All", icon='COLORSET_04_VEC').button_input = 'BATCH_RESTORE' 744 | col.separator(factor=1.0) 745 | col.prop(self, 'dry_run') 746 | col.prop(self, 'clean_path') 747 | col.prop(self, 'advanced_mode') 748 | if self.advanced_mode: 749 | col.prop(self, 'expand_version_selection') 750 | 751 | def draw_selection(self, box): 752 | if self.tabs == 'BACKUP': 753 | box = box.box() 754 | row = box.row() 755 | col = row.column() 756 | col.prop(self, 'backup_addons') 757 | col.prop(self, 'backup_extensions') 758 | col.prop(self, 'backup_presets') 759 | col.prop(self, 'backup_datafile') 760 | 761 | col = row.column() 762 | col.prop(self, 'backup_startup_blend') 763 | col.prop(self, 'backup_userpref_blend') 764 | col.prop(self, 'backup_workspaces_blend') 765 | 766 | col = row.column() 767 | col.prop(self, 'backup_cache') 768 | col.prop(self, 'backup_bookmarks') 769 | col.prop(self, 'backup_recentfiles') 770 | 771 | elif self.tabs == 'RESTORE': 772 | box = box.box() 773 | row = box.row() 774 | col = row.column() 775 | col.prop(self, 'restore_addons') 776 | col.prop(self, 'restore_extensions') 777 | col.prop(self, 'restore_presets') 778 | col.prop(self, 'restore_datafile') 779 | 780 | col = row.column() 781 | col.prop(self, 'restore_startup_blend') 782 | col.prop(self, 'restore_userpref_blend') 783 | col.prop(self, 'restore_workspaces_blend') 784 | 785 | col = row.column() 786 | col.prop(self, 'restore_cache') 787 | col.prop(self, 'restore_bookmarks') 788 | col.prop(self, 'restore_recentfiles') 789 | 790 | 791 | --------------------------------------------------------------------------------