Files
silo/pkg/freecad/silo_origin.py
Forbes 31586755b7 feat(origin): add interactive open/saveAs methods to SiloOrigin
Add openDocumentInteractive() and saveDocumentAsInteractive() methods
to support the updated FileOrigin interface from Issue #10/#12.

- openDocumentInteractive: Delegates to Silo_Open command for search dialog
- saveDocumentAsInteractive: Triggers new item creation form for Save As

These methods enable the Std_* commands in FreeCAD to delegate to
SiloOrigin when Silo is the active origin.
2026-02-05 14:02:07 -06:00

585 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 FreeCAD
import FreeCADGui
from .silo_commands import (
_client,
_sync,
get_tracked_object,
set_silo_properties,
find_file_by_part_number,
collect_document_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 name for BitmapFactory."""
return "silo"
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 authentication dialog if not already authenticated.
Returns:
True if authenticated after this call
"""
if _client.is_authenticated():
return True
# Show auth dialog via existing Silo_Auth command
try:
cmd = FreeCADGui.Command.get("Silo_Auth")
if cmd:
cmd.Activated()
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:
cmd = FreeCADGui.Command.get("Silo_New")
if cmd:
cmd.Activated()
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:
cmd = FreeCADGui.Command.get("Silo_Open")
if cmd:
cmd.Activated()
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:
cmd = FreeCADGui.Command.get("Silo_Open")
if cmd:
cmd.Activated()
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
doc.Modified = False
return True
except Exception as e:
FreeCAD.Console.PrintError(f"Silo save failed: {e}\n")
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:
cmd = FreeCADGui.Command.get("Silo_Commit")
if cmd:
cmd.Activated()
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:
cmd = FreeCADGui.Command.get("Silo_Pull")
if cmd:
cmd.Activated()
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:
cmd = FreeCADGui.Command.get("Silo_Push")
if cmd:
cmd.Activated()
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:
cmd = FreeCADGui.Command.get("Silo_Info")
if cmd:
cmd.Activated()
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:
cmd = FreeCADGui.Command.get("Silo_BOM")
if cmd:
cmd.Activated()
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