diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py index 32ba281114..22a6f2cb98 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 + use_context_deserialize = False + else: + # User declined import, use context-aware deserialization for external loading + use_context_deserialize = True + else: + # No external toolbits found, use regular deserialization + use_context_deserialize = False 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) + # Choose deserialization method based on whether toolbits were imported + if use_context_deserialize and hasattr(serializer_class, 'deep_deserialize_with_context'): + # Pass file path context for external dependency resolution + asset = serializer_class.deep_deserialize_with_context(raw_data, file_path) + else: + # Use regular deserialization - toolbits should be in stores now + 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,51 @@ 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_uri = self.asset_manager.add(toolbit, store="local") + 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/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py index f8fe8555fc..857db214c7 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -181,3 +181,88 @@ 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 + + # Generate a unique ID for this library instance + library_id = str(uuid.uuid4()) + + 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/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index e6f6991c6a..2c5ea7f33f 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."""