Files
silo-mod/freecad/silo_origin.py
forbes-0023 4d13313e42 fix(origin): return absolute icon path from SiloOrigin.icon()
The icon() method returned bare 'silo' which BitmapFactory could not
resolve. Return the absolute path to silo.svg so the C++ side can load
the icon directly.
2026-02-12 13:40:56 -06:00

593 lines
17 KiB
Python

"""Silo origin adapter for FreeCAD Origin system.
This module provides the SiloOrigin class that implements the FileOrigin
interface, allowing Silo to be used as a document origin in the unified
origin system introduced in Issue #9.
The SiloOrigin wraps existing Silo commands and SiloSync functionality,
delegating operations to the established Silo infrastructure while
providing the standardized origin interface.
"""
import os
import FreeCAD
import FreeCADGui
from silo_commands import (
_client,
_sync,
collect_document_properties,
find_file_by_part_number,
get_tracked_object,
set_silo_properties,
)
class SiloOrigin:
"""FileOrigin implementation for Silo PLM.
This class adapts Silo functionality to the FileOrigin interface,
enabling Silo to be used as a document origin in the unified system.
Key behaviors:
- Documents are always stored locally (hybrid local-remote model)
- Database tracks metadata, part numbers, and revision history
- MinIO stores revision snapshots for sync/backup
- Identity is tracked by UUID (SiloItemId), displayed as part number
"""
def __init__(self, origin_id="silo", nickname="Silo"):
"""Initialize SiloOrigin.
Args:
origin_id: Unique identifier for this origin instance
nickname: Short display name for UI elements
"""
self._id = origin_id
self._nickname = nickname
# =========================================================================
# Identity Methods
# =========================================================================
def id(self) -> str:
"""Return unique identifier for this origin."""
return self._id
def name(self) -> str:
"""Return display name for UI."""
return "Kindred Silo"
def nickname(self) -> str:
"""Return short nickname for compact UI elements."""
return self._nickname
def icon(self) -> str:
"""Return icon path for BitmapFactory."""
return os.path.join(
os.path.dirname(os.path.abspath(__file__)), "resources", "icons", "silo.svg"
)
def type(self) -> int:
"""Return origin type (OriginType.PLM = 1)."""
return 1
# =========================================================================
# Workflow Characteristics
# =========================================================================
def tracksExternally(self) -> bool:
"""Return True - Silo tracks documents in database."""
return True
def requiresAuthentication(self) -> bool:
"""Return True - Silo requires user authentication."""
return True
# =========================================================================
# Capabilities
# =========================================================================
def supportsRevisions(self) -> bool:
"""Return True - Silo supports revision history."""
return True
def supportsBOM(self) -> bool:
"""Return True - Silo supports Bill of Materials."""
return True
def supportsPartNumbers(self) -> bool:
"""Return True - Silo assigns part numbers from schema."""
return True
def supportsAssemblies(self) -> bool:
"""Return True - Silo supports assembly documents."""
return True
# =========================================================================
# Connection State
# =========================================================================
def connectionState(self) -> int:
"""Return connection state enum value.
Returns:
0 = Disconnected
1 = Connecting
2 = Connected
3 = Error
"""
if not _client.is_authenticated():
return 0 # Disconnected
try:
ok, _ = _client.check_connection()
return 2 if ok else 3 # Connected or Error
except Exception:
return 3 # Error
def connect(self) -> bool:
"""Trigger authentication if needed.
Shows the Silo login dialog if not already authenticated.
Returns:
True if authenticated after this call
"""
if _client.is_authenticated():
try:
ok, _ = _client.check_connection()
if ok:
return True
except Exception:
pass
# Show login dialog directly
try:
from PySide import QtWidgets
mw = FreeCADGui.getMainWindow()
if mw is None:
return False
# Find or create the auth dock widget and trigger its login dialog
panel = mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseAuth")
if panel and hasattr(panel, "_auth"):
panel._auth._show_login_dialog()
else:
# Fallback: run the Settings command so the user can configure
FreeCADGui.runCommand("Silo_Settings")
return _client.is_authenticated()
except Exception as e:
FreeCAD.Console.PrintError(f"Silo connect failed: {e}\n")
return False
def disconnect(self):
"""Log out of Silo."""
_client.logout()
# =========================================================================
# Document Identity
# =========================================================================
def documentIdentity(self, doc) -> str:
"""Return UUID (SiloItemId) as primary identity.
The UUID is the immutable tracking key for the document in the
database. Falls back to part number if UUID not yet assigned.
Args:
doc: FreeCAD App.Document
Returns:
UUID string, or part number as fallback, or empty string
"""
if not doc:
return ""
obj = get_tracked_object(doc)
if not obj:
return ""
# Prefer UUID (SiloItemId)
if hasattr(obj, "SiloItemId") and obj.SiloItemId:
return obj.SiloItemId
# Fallback to part number
if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber:
return obj.SiloPartNumber
return ""
def documentDisplayId(self, doc) -> str:
"""Return part number for display.
The part number is the human-readable identifier shown in the UI.
Args:
doc: FreeCAD App.Document
Returns:
Part number string or empty string
"""
if not doc:
return ""
obj = get_tracked_object(doc)
if obj and hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber:
return obj.SiloPartNumber
return ""
def ownsDocument(self, doc) -> bool:
"""Check if document is tracked by Silo.
A document is owned by Silo if it has a tracked object with
SiloItemId or SiloPartNumber property set.
Args:
doc: FreeCAD App.Document
Returns:
True if Silo owns this document
"""
if not doc:
return False
obj = get_tracked_object(doc)
if not obj:
return False
# Check for SiloItemId (preferred) or SiloPartNumber
if hasattr(obj, "SiloItemId") and obj.SiloItemId:
return True
if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber:
return True
return False
# =========================================================================
# Property Sync
# =========================================================================
def syncProperties(self, doc) -> bool:
"""Sync document properties to database.
Pushes syncable properties from the FreeCAD document to the
Silo database.
Args:
doc: FreeCAD App.Document
Returns:
True if sync succeeded
"""
if not doc:
return False
obj = get_tracked_object(doc)
if not obj or not hasattr(obj, "SiloPartNumber"):
return False
try:
# Collect syncable properties
updates = {}
if hasattr(obj, "SiloDescription") and obj.SiloDescription:
updates["description"] = obj.SiloDescription
if updates:
_client.update_item(obj.SiloPartNumber, **updates)
return True
except Exception as e:
FreeCAD.Console.PrintError(f"Silo property sync failed: {e}\n")
return False
# =========================================================================
# Core Operations
# =========================================================================
def newDocument(self, name: str = ""):
"""Create new document via Silo part creation form.
Delegates to the existing Silo_New command which:
1. Shows part creation dialog with category selection
2. Generates part number from schema
3. Creates document with Silo properties
Args:
name: Optional document name (not used, Silo assigns name)
Returns:
Created App.Document or None
"""
try:
FreeCADGui.runCommand("Silo_New")
return FreeCAD.ActiveDocument
except Exception as e:
FreeCAD.Console.PrintError(f"Silo new document failed: {e}\n")
return None
def openDocument(self, identity: str):
"""Open document by UUID or part number.
If identity is empty, shows the Silo search dialog.
Otherwise, finds the local file or downloads from Silo.
Args:
identity: UUID or part number, or empty for search dialog
Returns:
Opened App.Document or None
"""
if not identity:
# No identity - show search dialog
try:
FreeCADGui.runCommand("Silo_Open")
return FreeCAD.ActiveDocument
except Exception as e:
FreeCAD.Console.PrintError(f"Silo open failed: {e}\n")
return None
# Try to find existing local file by part number
# (UUID lookup would require API enhancement)
local_path = find_file_by_part_number(identity)
if local_path and local_path.exists():
return FreeCAD.openDocument(str(local_path))
# Download from Silo
try:
doc = _sync.open_item(identity)
return doc
except Exception as e:
FreeCAD.Console.PrintError(f"Silo open item failed: {e}\n")
return None
def openDocumentInteractive(self):
"""Open document interactively via Silo search dialog.
Shows the Silo_Open dialog for searching and selecting
a document to open.
Returns:
Opened App.Document or None
"""
try:
FreeCADGui.runCommand("Silo_Open")
return FreeCAD.ActiveDocument
except Exception as e:
FreeCAD.Console.PrintError(f"Silo open failed: {e}\n")
return None
def saveDocument(self, doc) -> bool:
"""Save document and sync to Silo.
Saves the document locally to the canonical path and uploads
to Silo for sync.
Args:
doc: FreeCAD App.Document
Returns:
True if save succeeded
"""
if not doc:
return False
obj = get_tracked_object(doc)
if not obj:
# Not a Silo document - just save locally
if doc.FileName:
doc.save()
return True
return False
try:
# Save to canonical path
file_path = _sync.save_to_canonical_path(doc)
if not file_path:
FreeCAD.Console.PrintError("Failed to save to canonical path\n")
return False
# Upload to Silo
properties = collect_document_properties(doc)
_client._upload_file(obj.SiloPartNumber, str(file_path), properties, comment="")
# Clear modified flag (Modified is on Gui.Document, not App.Document)
gui_doc = FreeCADGui.getDocument(doc.Name)
if gui_doc:
gui_doc.Modified = False
return True
except Exception as e:
import traceback
FreeCAD.Console.PrintError(f"Silo save failed: {e}\n")
FreeCAD.Console.PrintError(traceback.format_exc())
return False
def saveDocumentAs(self, doc, newIdentity: str) -> bool:
"""Save with new identity - triggers migration or copy workflow.
For local documents: Triggers migration to Silo (new item creation)
For Silo documents: Would trigger copy workflow (not yet implemented)
Args:
doc: FreeCAD App.Document
newIdentity: New identity (currently unused)
Returns:
True if operation succeeded
"""
if not doc:
return False
obj = get_tracked_object(doc)
if not obj:
# Local document being migrated to Silo
# Trigger new item creation form
result = self.newDocument()
return result is not None
# Already a Silo document - copy workflow
# TODO: Issue #17 will implement copy workflow
FreeCAD.Console.PrintWarning(
"Silo copy workflow not yet implemented. Use Silo_New to create a new item.\n"
)
return False
def saveDocumentAsInteractive(self, doc) -> bool:
"""Save document interactively with new identity.
For Silo, this triggers the new item creation form which allows
the user to select category and create a new part number.
Args:
doc: FreeCAD App.Document
Returns:
True if operation succeeded
"""
if not doc:
return False
# For Silo, "Save As" means creating a new item
# Trigger the new item creation form
result = self.newDocument()
return result is not None
# =========================================================================
# Extended Operations
# =========================================================================
def commitDocument(self, doc) -> bool:
"""Commit with revision comment.
Delegates to Silo_Commit command.
Args:
doc: FreeCAD App.Document
Returns:
True if command was executed
"""
try:
FreeCADGui.runCommand("Silo_Commit")
return True
except Exception as e:
FreeCAD.Console.PrintError(f"Silo commit failed: {e}\n")
return False
def pullDocument(self, doc) -> bool:
"""Pull latest from Silo.
Delegates to Silo_Pull command.
Args:
doc: FreeCAD App.Document
Returns:
True if command was executed
"""
try:
FreeCADGui.runCommand("Silo_Pull")
return True
except Exception as e:
FreeCAD.Console.PrintError(f"Silo pull failed: {e}\n")
return False
def pushDocument(self, doc) -> bool:
"""Push changes to Silo.
Delegates to Silo_Push command.
Args:
doc: FreeCAD App.Document
Returns:
True if command was executed
"""
try:
FreeCADGui.runCommand("Silo_Push")
return True
except Exception as e:
FreeCAD.Console.PrintError(f"Silo push failed: {e}\n")
return False
def showInfo(self, doc):
"""Show document info dialog.
Delegates to Silo_Info command.
Args:
doc: FreeCAD App.Document
"""
try:
FreeCADGui.runCommand("Silo_Info")
except Exception as e:
FreeCAD.Console.PrintError(f"Silo info failed: {e}\n")
def showBOM(self, doc):
"""Show BOM dialog.
Delegates to Silo_BOM command.
Args:
doc: FreeCAD App.Document
"""
try:
FreeCADGui.runCommand("Silo_BOM")
except Exception as e:
FreeCAD.Console.PrintError(f"Silo BOM failed: {e}\n")
# =============================================================================
# Module-level functions
# =============================================================================
# Global instance
_silo_origin = None
def get_silo_origin():
"""Get or create the global SiloOrigin instance.
Returns:
SiloOrigin instance
"""
global _silo_origin
if _silo_origin is None:
_silo_origin = SiloOrigin()
return _silo_origin
def register_silo_origin():
"""Register SiloOrigin with FreeCADGui.
This should be called during workbench initialization to make
Silo available as a file origin.
"""
origin = get_silo_origin()
try:
FreeCADGui.addOrigin(origin)
FreeCAD.Console.PrintLog("Registered Silo origin\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n")
def unregister_silo_origin():
"""Unregister SiloOrigin from FreeCADGui.
This should be called during workbench cleanup if needed.
"""
global _silo_origin
if _silo_origin:
try:
FreeCADGui.removeOrigin(_silo_origin)
FreeCAD.Console.PrintLog("Unregistered Silo origin\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not unregister Silo origin: {e}\n")
_silo_origin = None