Files
silo-mod/freecad/silo_origin.py
Zoe Forbes c537e2f08f fix: remove MinIO references and degraded mode
The silo server now uses filesystem storage instead of MinIO.

- Remove all MinIO references from docstrings, tooltips, and UI text
- Remove obsolete 'degraded' server mode (no separate storage service)
- Update Silo_Info display: 'File in MinIO' → 'File on Server'
- Update SiloOrigin class docstring
2026-02-18 14:55:57 -06:00

584 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,
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
- Server stores revision files 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 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.
"""
from kindred_sdk import register_origin
register_origin(get_silo_origin())
def unregister_silo_origin():
"""Unregister SiloOrigin from FreeCADGui.
This should be called during workbench cleanup if needed.
"""
global _silo_origin
if _silo_origin:
from kindred_sdk import unregister_origin
unregister_origin(_silo_origin)
_silo_origin = None