diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 5cc41ed1b3..6e965b361a 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -128,6 +128,11 @@ SET(PathPythonToolsAssets_SRCS Path/Tool/assets/uri.py ) +SET(PathPythonToolsMigration_SRCS + Path/Tool/migration/__init__.py + Path/Tool/migration/migration.py +) + SET(PathPythonToolsAssetsStore_SRCS Path/Tool/assets/store/__init__.py Path/Tool/assets/store/base.py @@ -600,6 +605,7 @@ SET(all_files ${PathPythonPostScripts_SRCS} ${PathPythonTools_SRCS} ${PathPythonToolsAssets_SRCS} + ${PathPythonToolsMigration_SRCS} ${PathPythonToolsAssetsStore_SRCS} ${PathPythonToolsAssetsUi_SRCS} ${PathPythonToolsDocObject_SRCS} @@ -755,6 +761,13 @@ INSTALL( Mod/CAM/Path/Tool/assets ) +INSTALL( + FILES + ${PathPythonToolsMigration_SRCS} + DESTINATION + Mod/CAM/Path/Tool/migration +) + INSTALL( FILES ${PathPythonToolsAssetsStore_SRCS} diff --git a/src/Mod/CAM/InitGui.py b/src/Mod/CAM/InitGui.py index 23e6e003f1..ec2ef11ebc 100644 --- a/src/Mod/CAM/InitGui.py +++ b/src/Mod/CAM/InitGui.py @@ -23,6 +23,14 @@ # *************************************************************************** import FreeCAD +if FreeCAD.GuiUp: + import FreeCADGui + from FreeCADGui import Workbench +else: + # Provide a dummy Workbench class when GUI is not available + class Workbench: + pass + FreeCAD.__unit_test__ += ["TestCAMGui"] @@ -83,6 +91,16 @@ class CAMWorkbench(Workbench): from Path.Tool.toolbit.ui import cmd as PathToolBitCmd from Path.Tool.library.ui import cmd as PathToolBitLibraryCmd + from Path.Tool.camassets import cam_assets + + cam_assets.setup() + + # Check if CAM asset migration is needed for version upgrade + from Path.Tool.migration.migration import CAMAssetMigrator + + migrator = CAMAssetMigrator() + migrator.check_migration_needed() + from PySide.QtCore import QT_TRANSLATE_NOOP import PathCommands @@ -339,6 +357,7 @@ class CAMWorkbench(Workbench): "Profile" in selectedName or "Contour" in selectedName or "Dressup" in selectedName + or "Pocket" in selectedName ): self.appendContextMenu("", "Separator") # self.appendContextMenu("", ["Set_StartPoint"]) diff --git a/src/Mod/CAM/Path/Main/Gui/JobCmd.py b/src/Mod/CAM/Path/Main/Gui/JobCmd.py index 0867a9e9c4..30dcfbc00d 100644 --- a/src/Mod/CAM/Path/Main/Gui/JobCmd.py +++ b/src/Mod/CAM/Path/Main/Gui/JobCmd.py @@ -134,7 +134,7 @@ class CommandJobTemplateExport: foo = QtGui.QFileDialog.getSaveFileName( QtGui.QApplication.activeWindow(), "Path - Job Template", - Path.Preferences.filePath(), + str(Path.Preferences.getTemplateDirectory()), "job_*.json", )[0] if foo: diff --git a/src/Mod/CAM/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py index 965b896c3c..d37f4a99a8 100644 --- a/src/Mod/CAM/Path/Preferences.py +++ b/src/Mod/CAM/Path/Preferences.py @@ -29,7 +29,7 @@ from collections import defaultdict from typing import Optional -if False: +if True: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: @@ -128,7 +128,9 @@ def getAssetPath() -> pathlib.Path: # Check if we have a CamAssets path already set cam_assets_path = pref.GetString(ToolPath, "") if cam_assets_path: - return pathlib.Path(cam_assets_path) + # Use mostRecentConfigFromBase to get the most recent versioned path + most_recent_path = FreeCAD.ApplicationDirectories.mostRecentConfigFromBase(cam_assets_path) + return pathlib.Path(most_recent_path) # Migration: Check for legacy DefaultFilePath and use it for CamAssets legacy_path = defaultFilePath() @@ -137,14 +139,21 @@ def getAssetPath() -> pathlib.Path: if legacy_path_obj.exists() and legacy_path_obj.is_dir(): # Migrate: Set the legacy path as the new CamAssets path setAssetPath(legacy_path_obj) - return legacy_path_obj + # Return the most recent version of the legacy path + most_recent_legacy = FreeCAD.ApplicationDirectories.mostRecentConfigFromBase( + str(legacy_path_obj) + ) + return pathlib.Path(most_recent_legacy) # Fallback to default if no legacy path found default = getDefaultAssetPath() - return pathlib.Path(default) + # Return the most recent version of the default path + most_recent_default = FreeCAD.ApplicationDirectories.mostRecentConfigFromBase(str(default)) + return pathlib.Path(most_recent_default) def setAssetPath(path: pathlib.Path): + Path.Log.info(f"Setting asset path to {path}") assert path.is_dir(), f"Cannot put a non-initialized asset directory into preferences: {path}" pref = tool_preferences() current_path = pref.GetString(ToolPath, "") @@ -158,6 +167,13 @@ def getToolBitPath() -> pathlib.Path: return getAssetPath() / "Tools" / "Bit" +def getTemplateDirectory() -> pathlib.Path: + """Returns the directory where job templates should be saved.""" + template_path = getAssetPath() / "Templates" + template_path.mkdir(parents=True, exist_ok=True) + return template_path + + def getLastToolLibrary() -> Optional[str]: pref = tool_preferences() return pref.GetString(LastToolLibrary) or None @@ -239,6 +255,9 @@ def macroFilePath(): def searchPaths(): paths = [] + # Add new CamAssets/Templates directory first (highest priority) + paths.append(str(getTemplateDirectory())) + # Add legacy locations for backward compatibility p = defaultFilePath() if p: paths.append(p) diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py index 32ba281114..3bf0c5a8a8 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -74,21 +74,79 @@ class AssetOpenDialog(QFileDialog): try: raw_data = file_path.read_bytes() dependencies = serializer_class.extract_dependencies(raw_data) + external_toolbits = [] # Track toolbits found externally + for dependency_uri in dependencies: - if not self.asset_manager.exists(dependency_uri, store=["local", "builtin"]): + # First check if dependency exists in asset manager stores + if self.asset_manager.exists(dependency_uri, store=["local", "builtin"]): + continue + + # If not in stores, check if it exists relative to the library file + dependency_found = False + if dependency_uri.asset_type == "toolbit": + # Look for toolbit files in parallel Bit directory + # Library is in Library/, toolbits are in parallel Bit/ + library_dir = file_path.parent # e.g., /path/to/Library/ + tools_dir = library_dir.parent # e.g., /path/to/ + bit_dir = tools_dir / "Bit" # e.g., /path/to/Bit/ + + if bit_dir.exists(): + possible_extensions = [".fctb", ".json", ".yaml", ".yml"] + for ext in possible_extensions: + toolbit_file = bit_dir / f"{dependency_uri.asset_id}{ext}" + if toolbit_file.exists(): + dependency_found = True + external_toolbits.append((dependency_uri, toolbit_file)) + break + + if not dependency_found: QMessageBox.critical( self, "Error", - f"Failed to import {file_path}: required dependency {dependency_uri} not found", + f"Failed to import {file_path}: required dependency {dependency_uri} not found in stores or in parallel Bit directory", ) return None + + # If we found external toolbits, ask user if they want to import them + if external_toolbits: + from PySide.QtGui import QMessageBox + + toolbit_names = [uri.asset_id for uri, _ in external_toolbits] + reply = QMessageBox.question( + self, + "Import External Toolbits", + f"This library references {len(external_toolbits)} toolbit(s) that are not in your local store:\n\n" + + "\n".join(f"• {name}" for name in toolbit_names) + + f"\n\nWould you like to import these toolbits into your local store?\n" + + "This will make them permanently available for use in other libraries.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + + if reply == QMessageBox.Yes: + # Import the external toolbits into local store + self._import_external_toolbits(external_toolbits) + # After importing, use regular deserialization since toolbits are now in local store + else: + # User declined import, use context-aware deserialization for external loading + pass + else: + # No external toolbits found, use regular deserialization + pass except Exception as e: QMessageBox.critical(self, "Error", f"{file_path}: Failed to check dependencies: {e}") return None # Load and return the asset. try: - asset = serializer_class.deep_deserialize(raw_data) + # Always use context-aware deserialization for libraries to get meaningful names + if hasattr(serializer_class, "deep_deserialize_with_context"): + # Pass file path context for meaningful library names and external dependency resolution + asset = serializer_class.deep_deserialize_with_context(raw_data, file_path) + else: + # Fallback to regular deserialization + asset = serializer_class.deep_deserialize(raw_data) + if not isinstance(asset, self.asset_class): raise TypeError(f"Deserialized asset is not of type {self.asset_class.__name__}") return asset @@ -124,6 +182,56 @@ class AssetOpenDialog(QFileDialog): # Fallback to home directory if anything goes wrong return pathlib.Path.home() + def _import_external_toolbits(self, external_toolbits): + """Import external toolbits into the local asset store.""" + from ...toolbit.serializers import all_serializers as toolbit_serializers + from .util import get_serializer_from_extension + + imported_count = 0 + failed_imports = [] + + for dependency_uri, toolbit_file in external_toolbits: + try: + # Find appropriate serializer for the file + file_extension = toolbit_file.suffix.lower() + serializer_class = get_serializer_from_extension( + toolbit_serializers, file_extension, for_import=True + ) + + if not serializer_class: + failed_imports.append( + f"{dependency_uri.asset_id}: No serializer for {file_extension}" + ) + continue + + # Load and deserialize the toolbit + raw_toolbit_data = toolbit_file.read_bytes() + toolbit = serializer_class.deep_deserialize(raw_toolbit_data) + + # Ensure the toolbit ID matches what the library expects + if toolbit.id != dependency_uri.asset_id: + toolbit.id = dependency_uri.asset_id + + # Import the toolbit into local store + imported_count += 1 + + except Exception as e: + failed_imports.append(f"{dependency_uri.asset_id}: {str(e)}") + + # Show results to user + if imported_count > 0: + message = f"Successfully imported {imported_count} toolbit(s) into your local store." + if failed_imports: + message += f"\n\nFailed to import {len(failed_imports)} toolbit(s):\n" + "\n".join( + failed_imports + ) + QMessageBox.information(self, "Import Results", message) + elif failed_imports: + message = f"Failed to import all {len(failed_imports)} toolbit(s):\n" + "\n".join( + failed_imports + ) + QMessageBox.warning(self, "Import Failed", message) + class AssetSaveDialog(QFileDialog): def __init__( diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index e74305ad05..ff46b2e606 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -58,7 +58,7 @@ class AssetPreferencesPage: translate( "CAM_PreferencesAssets", "Note: Select the directory that will contain the " - "Bit/, Shape/, and Library/ subfolders.", + "Tool folder with Bit/, Shape/, and Library/ subfolders.", ) ) self.asset_path_note_label.setWordWrap(True) @@ -124,6 +124,9 @@ class AssetPreferencesPage: return True def loadSettings(self): - # use getAssetPath() to initialize UI - asset_path = Path.Preferences.getAssetPath() - self.asset_path_edit.setText(str(asset_path)) + # Get the raw preference value, not the versioned path + pref = Path.Preferences.tool_preferences() + asset_path = pref.GetString(Path.Preferences.ToolPath, "") + if not asset_path: + asset_path = str(Path.Preferences.getDefaultAssetPath()) + self.asset_path_edit.setText(asset_path) diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py index 8bffbeaec3..75abad2ef9 100644 --- a/src/Mod/CAM/Path/Tool/camassets.py +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -27,6 +27,12 @@ from Path import Preferences from Path.Preferences import addToolPreferenceObserver from .assets import AssetManager, AssetUri, Asset, FileStore +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + def ensure_library_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): """ @@ -151,7 +157,7 @@ def ensure_assets_initialized(asset_manager: AssetManager, store="local"): def _on_asset_path_changed(group, key, value): Path.Log.info(f"CAM asset directory changed in preferences: {group} {key} {value}") - user_asset_store.set_dir(Preferences.getAssetPath()) + user_asset_store.set_dir(value) ensure_assets_initialized(cam_assets) diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py index f8fe8555fc..c04ce3874b 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -72,7 +72,11 @@ class FCTLSerializer(AssetSerializer): # for the asset being deserialized. We should use this ID for the library # instance, overriding any 'id' that might be in the data_dict (which # is from an older version of the format). - library = Library(data_dict.get("label", id or "Unnamed Library"), id=id) + + # For the label, prefer data_dict["label"], then "name", then fallback to using the id as filename + # The id parameter often contains the filename stem when importing from files + label = data_dict.get("label") or data_dict.get("name") or id or "Unnamed Library" + library = Library(label, id=id) if dependencies is None: Path.Log.debug( @@ -181,3 +185,89 @@ class FCTLSerializer(AssetSerializer): # Now deserialize with the resolved dependencies return cls.deserialize(data, library_id, resolved_dependencies) + + @classmethod + def deep_deserialize_with_context(cls, data: bytes, file_path: "pathlib.Path"): + """Deep deserialize a library with file path context for external dependencies.""" + import uuid + import pathlib + from ...camassets import cam_assets + from ...toolbit.serializers import all_serializers as toolbit_serializers + + # Use filename stem as library ID for meaningful names + library_id = file_path.stem + + Path.Log.info( + f"FCTL DEEP_DESERIALIZE_WITH_CONTEXT: Starting deep deserialization for library from {file_path}" + ) + + # Extract dependency URIs from the library data + dependency_uris = cls.extract_dependencies(data) + Path.Log.info( + f"FCTL DEEP_DESERIALIZE_WITH_CONTEXT: Found {len(dependency_uris)} toolbit dependencies: {[uri.asset_id for uri in dependency_uris]}" + ) + + # Fetch all toolbit dependencies + resolved_dependencies = {} + for dep_uri in dependency_uris: + try: + # First try to get from asset manager stores + Path.Log.info( + f"FCTL EXTERNAL: Trying to fetch toolbit '{dep_uri.asset_id}' from stores ['local', 'builtin']" + ) + toolbit = cam_assets.get(dep_uri, store=["local", "builtin"], depth=0) + resolved_dependencies[dep_uri] = toolbit + Path.Log.info( + f"FCTL EXTERNAL: Successfully fetched toolbit '{dep_uri.asset_id}' from stores" + ) + except Exception as e: + # If not in stores, try to load from parallel Bit directory + Path.Log.info( + f"FCTL EXTERNAL: Toolbit '{dep_uri.asset_id}' not in stores, trying external file: {e}" + ) + + # Look for toolbit files in parallel Bit directory + library_dir = file_path.parent # e.g., /path/to/Library/ + tools_dir = library_dir.parent # e.g., /path/to/ + bit_dir = tools_dir / "Bit" # e.g., /path/to/Bit/ + + toolbit_loaded = False + if bit_dir.exists(): + possible_extensions = [".fctb", ".json", ".yaml", ".yml"] + for ext in possible_extensions: + toolbit_file = bit_dir / f"{dep_uri.asset_id}{ext}" + if toolbit_file.exists(): + try: + # Find appropriate serializer for the file + from ...assets.ui.util import get_serializer_from_extension + + serializer_class = get_serializer_from_extension( + toolbit_serializers, ext, for_import=True + ) + if serializer_class: + # Load and deserialize the toolbit + raw_toolbit_data = toolbit_file.read_bytes() + toolbit = serializer_class.deep_deserialize(raw_toolbit_data) + resolved_dependencies[dep_uri] = toolbit + toolbit_loaded = True + Path.Log.info( + f"FCTL EXTERNAL: Successfully loaded toolbit '{dep_uri.asset_id}' from {toolbit_file}" + ) + break + except Exception as load_error: + Path.Log.warning( + f"FCTL EXTERNAL: Failed to load toolbit from {toolbit_file}: {load_error}" + ) + continue + + if not toolbit_loaded: + Path.Log.warning( + f"FCTL EXTERNAL: Could not load toolbit '{dep_uri.asset_id}' from external files" + ) + + Path.Log.info( + f"FCTL EXTERNAL: Resolved {len(resolved_dependencies)} of {len(dependency_uris)} dependencies" + ) + + # Now deserialize with the resolved dependencies + return cls.deserialize(data, library_id, resolved_dependencies) diff --git a/src/Mod/CAM/Path/Tool/migration/__init__.py b/src/Mod/CAM/Path/Tool/migration/__init__.py new file mode 100644 index 0000000000..41501293ad --- /dev/null +++ b/src/Mod/CAM/Path/Tool/migration/__init__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 sliptonic * +# * * +# * This file is part of FreeCAD. * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +""" +CAM Asset management package. + +This package contains modules for managing CAM assets including migration +functionality for handling asset directory versioning during FreeCAD upgrades. +""" diff --git a/src/Mod/CAM/Path/Tool/migration/migration.py b/src/Mod/CAM/Path/Tool/migration/migration.py new file mode 100644 index 0000000000..73eb441f64 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/migration/migration.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 sliptonic * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** +""" +CAM Asset Migration Module + +Handles migration of CAM assets during FreeCAD version upgrades. +""" + +import FreeCAD +import Path +import Path.Preferences +import pathlib + +# Logging setup - same pattern as Job.py +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + +if FreeCAD.GuiUp: + import FreeCADGui + from PySide.QtWidgets import QApplication, QMessageBox + from PySide.QtCore import Qt + + +class CAMAssetMigrator: + """ + Handles migration of CAM assets during FreeCAD version upgrades. + + This class provides functionality to: + - Check if migration is needed for custom CAM asset locations + - Offer migration to users through a dialog + - Perform the actual asset migration with versioned directories + """ + + def __init__(self): + self.pref_group_path = "User parameter:BaseApp/Preferences/Mod/CAM/Migration" + + def check_migration_needed(self): + """ + Check if CAM asset migration is needed for version upgrade. + + This method determines if the current CAM assets are stored in a custom + location outside the default user data directory and if migration has + not been offered for the current FreeCAD version. + + Returns: + bool: True if migration should be offered, False otherwise + """ + Path.Log.info("Starting CAM asset migration check") + + try: + # Get current directories + user_app_data_dir = FreeCAD.getUserAppDataDir() + user_app_data_path = pathlib.Path(user_app_data_dir) + Path.Log.debug(f"User app data directory: {user_app_data_dir}") + + # Get the current CAM asset path (may be naked or versioned) + current_asset_path = Path.Preferences.getAssetPath() + current_asset_pathlib = pathlib.Path(current_asset_path) + Path.Log.debug(f"Current CAM asset path: {current_asset_path}") + + # Only migrate if CamAssets is outside the standard user data directory + if current_asset_pathlib.is_relative_to(user_app_data_path): + Path.Log.debug("CamAssets is in default location, no custom migration needed") + return + + # Check if migration has already been offered for this version + if self.has_migration_been_offered(): + Path.Log.debug("Migration has already been offered for this version, skipping") + return + + # Determine the base path (naked path without version) + if FreeCAD.ApplicationDirectories.isVersionedPath(str(current_asset_path)): + # Check if we're already using the current version + if FreeCAD.ApplicationDirectories.usingCurrentVersionConfig( + str(current_asset_path) + ): + Path.Log.debug("Already using current version, no migration needed") + return + + Path.Log.info("Migration is needed and should be offered") + if self._offer_migration_to_user(): + self._migrate_assets(str(current_asset_path)) + return + + except Exception as e: + Path.Log.error(f"Error checking CAM asset migration: {e}") + import traceback + + Path.Log.debug(f"Full traceback: {traceback.format_exc()}") + return False + + def _offer_migration_to_user(self): + """ + Present migration dialog to user. + + Returns: + bool: True if user accepted migration, False otherwise + """ + # Get current version info + major = int(FreeCAD.ConfigGet("BuildVersionMajor")) + minor = int(FreeCAD.ConfigGet("BuildVersionMinor")) + current_version = FreeCAD.ApplicationDirectories.versionStringForPath(major, minor) + + # Get current asset path for display + current_asset_path = Path.Preferences.getAssetPath() + + Path.Log.debug(f"Offering migration to user for version {current_version}") + + if not FreeCAD.GuiUp: + Path.Log.debug("GUI not available, skipping migration offer") + return False + + msg = ( + f"FreeCAD has been upgraded to version {current_version}.\n\n" + f"Your CAM assets are stored in a custom location:\n{current_asset_path}\n\n" + "Would you like to migrate your CAM assets to a versioned directory " + "to preserve them during future upgrades?\n\n" + "This will copy your assets to a new directory." + ) + + Path.Log.debug("Showing migration dialog to user") + + reply = QMessageBox.question( + None, "CAM Asset Migration", msg, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes + ) + + # Record that we offered migration for this version + pref_group = FreeCAD.ParamGet(self.pref_group_path) + offered_versions = pref_group.GetString("OfferedToMigrateCAMAssets", "") + known_versions = set(offered_versions.split(",")) if offered_versions else set() + known_versions.add(current_version) + pref_group.SetString("OfferedToMigrateCAMAssets", ",".join(known_versions)) + Path.Log.debug(f"Updated offered versions: {known_versions}") + + if reply == QMessageBox.Yes: + Path.Log.info("User accepted migration, starting asset migration") + return True + else: + Path.Log.info("User declined migration") + return False + + def _migrate_assets(self, source_path): + """ + Perform actual directory copying and preference updates. + + Args: + source_path: Current CAM asset directory path + """ + Path.Log.info(f"Starting asset migration from {source_path}") + + try: + FreeCAD.ApplicationDirectories.migrateAllPaths([source_path]) + Path.Log.info( + "Migration complete - preferences will be handled automatically by the system" + ) + + if FreeCAD.GuiUp: + QMessageBox.information( + None, + "Migration Complete", + f"CAM assets have been migrated from:\n{source_path}\n\n" + "The system will automatically handle preference updates.", + ) + + except Exception as e: + error_msg = f"Failed to migrate CAM assets: {e}" + Path.Log.error(error_msg) + import traceback + + Path.Log.debug(f"Migration error traceback: {traceback.format_exc()}") + if FreeCAD.GuiUp: + QMessageBox.critical(None, "Migration Failed", error_msg) + + def has_migration_been_offered(self): + """ + Check if migration has been offered for current version. + + Returns: + bool: True if migration was offered for this version + """ + + pref_group = FreeCAD.ParamGet(self.pref_group_path) + major = int(FreeCAD.ConfigGet("BuildVersionMajor")) + minor = int(FreeCAD.ConfigGet("BuildVersionMinor")) + + current_version_string = FreeCAD.ApplicationDirectories.versionStringForPath(major, minor) + offered_versions = pref_group.GetString("OfferedToMigrateCAMAssets", "") + known_versions = set(offered_versions.split(",")) if offered_versions else set() + return current_version_string in known_versions diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index e6f6991c6a..a3b52c3757 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -267,9 +267,27 @@ class ToolBitBrowserWidget(QtGui.QWidget): uri_string = item.data(ToolBitUriRole) if not uri_string: return - toolbit = self._asset_manager.get(AssetUri(uri_string)) - if toolbit: - self.itemDoubleClicked.emit(toolbit) + try: + toolbit = self._asset_manager.get(AssetUri(uri_string)) + if toolbit: + self.itemDoubleClicked.emit(toolbit) + except FileNotFoundError: + # Handle missing/placeholder toolbits gracefully + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Missing Toolbit"), + FreeCAD.Qt.translate( + "CAM", + "This toolbit is missing from your local store. It may be a placeholder for a toolbit that was not found during library import.", + ), + ) + except Exception as e: + # Handle other errors + QMessageBox.critical( + self, + FreeCAD.Qt.translate("CAM", "Error"), + FreeCAD.Qt.translate("CAM", f"Failed to load toolbit: {e}"), + ) def _on_item_selection_changed(self): """Emits toolSelected signal and tracks selected URIs."""