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.
585 lines
17 KiB
Python
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
|