feat(origin): implement SiloOrigin adapter for unified origin system
Implements Issue #11: Silo origin adapter This commit creates the SiloOrigin class that implements the FileOrigin interface introduced in Issue #9, enabling Silo to be used as a document origin in the unified file origin system. ## SiloOrigin Class (silo_origin.py) New Python module providing the FileOrigin implementation for Silo PLM: ### Identity Methods - id(): Returns 'silo' as unique identifier - name(): Returns 'Kindred Silo' for UI display - nickname(): Returns 'Silo' for compact UI elements - icon(): Returns 'silo' icon name - type(): Returns OriginType.PLM (1) ### Workflow Characteristics - tracksExternally(): True - Silo tracks documents in database - requiresAuthentication(): True - Silo requires login ### Capabilities - supportsRevisions(): True - supportsBOM(): True - supportsPartNumbers(): True - supportsAssemblies(): True ### Connection State - connectionState(): Checks auth status and API connectivity - connect(): Triggers Silo_Auth dialog if needed - disconnect(): Calls _client.logout() ### Document Identity (UUID-based tracking) - documentIdentity(): Returns SiloItemId (UUID) as primary identity - documentDisplayId(): Returns SiloPartNumber for human display - ownsDocument(): True if document has SiloItemId or SiloPartNumber ### Core Operations (delegate to existing commands) - newDocument(): Delegates to Silo_New command - openDocument(): Uses find_file_by_part_number or _sync.open_item - saveDocument(): Saves locally + uploads via _client._upload_file - saveDocumentAs(): Triggers migration workflow for local docs ### Extended Operations - commitDocument(): Delegates to Silo_Commit - pullDocument(): Delegates to Silo_Pull - pushDocument(): Delegates to Silo_Push - showInfo(): Delegates to Silo_Info - showBOM(): Delegates to Silo_BOM ### Module Functions - get_silo_origin(): Returns singleton instance - register_silo_origin(): Registers with FreeCADGui.addOrigin() - unregister_silo_origin(): Cleanup function ## UUID Tracking (silo_commands.py) Added SiloItemId property to all locations where Silo properties are set: 1. create_document_for_item() - Assembly objects (line 1115) 2. create_document_for_item() - Fallback Part objects (line 1131) 3. create_document_for_item() - Part objects (line 1145) 4. Silo_New.Activated() - Tagged existing objects (line 1471) The SiloItemId stores the database UUID (Item.ID) which is immutable, while SiloPartNumber remains the human-readable identifier that could theoretically change. Property structure on tracked objects: - SiloItemId: UUID from database (primary tracking key) - SiloPartNumber: Human-readable part number - SiloRevision: Current revision number - SiloItemType: 'part' or 'assembly' ## Workbench Integration (InitGui.py) SiloOrigin is automatically registered when the Silo workbench initializes: def Initialize(self): import silo_commands try: import silo_origin silo_origin.register_silo_origin() except Exception as e: FreeCAD.Console.PrintWarning(...) This makes Silo available as a file origin via: - FreeCADGui.listOrigins() -> includes 'silo' - FreeCADGui.getOrigin('silo') -> returns origin info dict - FreeCADGui.setActiveOrigin('silo') -> sets Silo as active ## Design Decisions 1. **Delegation Pattern**: SiloOrigin delegates to existing Silo commands rather than reimplementing logic, ensuring consistency and easier maintenance. 2. **UUID as Primary Identity**: documentIdentity() returns UUID (SiloItemId) for immutable tracking, while documentDisplayId() returns part number for user display. 3. **Graceful Fallback**: If SiloItemId is not present (legacy docs), falls back to SiloPartNumber for identity/ownership checks. 4. **Exception Handling**: All operations wrapped in try/except to prevent origin system failures from breaking FreeCAD. Refs: #11
This commit is contained in:
@@ -27,6 +27,14 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
||||
"""Called when workbench is first activated."""
|
||||
import silo_commands
|
||||
|
||||
# Register Silo as a file origin in the unified origin system
|
||||
try:
|
||||
import silo_origin
|
||||
|
||||
silo_origin.register_silo_origin()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n")
|
||||
|
||||
self.toolbar_commands = [
|
||||
"Silo_ToggleMode",
|
||||
"Separator",
|
||||
@@ -59,9 +67,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
||||
def _show_shortcut_recommendations(self):
|
||||
"""Show keyboard shortcut recommendations dialog on first activation."""
|
||||
try:
|
||||
param_group = FreeCAD.ParamGet(
|
||||
"User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
||||
)
|
||||
param_group = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/KindredSilo")
|
||||
if param_group.GetBool("ShortcutsShown", False):
|
||||
return
|
||||
param_group.SetBool("ShortcutsShown", True)
|
||||
|
||||
@@ -18,9 +18,7 @@ from PySide import QtCore
|
||||
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
||||
|
||||
# Configuration - preferences take priority over env vars
|
||||
SILO_PROJECTS_DIR = os.environ.get(
|
||||
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
|
||||
)
|
||||
SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects"))
|
||||
|
||||
|
||||
def _get_api_url() -> str:
|
||||
@@ -330,9 +328,7 @@ CATEGORY_NAMES = {
|
||||
|
||||
|
||||
# Icon directory - resolve relative to this file so it works regardless of install location
|
||||
_ICON_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "resources", "icons"
|
||||
)
|
||||
_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons")
|
||||
|
||||
|
||||
def _icon(name):
|
||||
@@ -363,9 +359,7 @@ class SiloClient:
|
||||
return self._explicit_url.rstrip("/")
|
||||
return _get_api_url().rstrip("/")
|
||||
|
||||
def _request(
|
||||
self, method: str, path: str, data: Optional[Dict] = None
|
||||
) -> Dict[str, Any]:
|
||||
def _request(self, method: str, path: str, data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""Make HTTP request to Silo API."""
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -479,9 +473,7 @@ class SiloClient:
|
||||
def get_item(self, part_number: str) -> Dict[str, Any]:
|
||||
return self._request("GET", f"/items/{part_number}")
|
||||
|
||||
def list_items(
|
||||
self, search: str = "", item_type: str = "", project: str = ""
|
||||
) -> list:
|
||||
def list_items(self, search: str = "", item_type: str = "", project: str = "") -> list:
|
||||
params = ["limit=100"]
|
||||
if search:
|
||||
params.append(f"search={urllib.parse.quote(search)}")
|
||||
@@ -532,13 +524,9 @@ class SiloClient:
|
||||
"""Get projects associated with an item."""
|
||||
return self._request("GET", f"/items/{part_number}/projects")
|
||||
|
||||
def add_item_projects(
|
||||
self, part_number: str, project_codes: List[str]
|
||||
) -> Dict[str, Any]:
|
||||
def add_item_projects(self, part_number: str, project_codes: List[str]) -> Dict[str, Any]:
|
||||
"""Add project tags to an item."""
|
||||
return self._request(
|
||||
"POST", f"/items/{part_number}/projects", {"projects": project_codes}
|
||||
)
|
||||
return self._request("POST", f"/items/{part_number}/projects", {"projects": project_codes})
|
||||
|
||||
def has_file(self, part_number: str) -> Tuple[bool, Optional[int]]:
|
||||
"""Check if item has files in MinIO."""
|
||||
@@ -562,9 +550,7 @@ class SiloClient:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def compare_revisions(
|
||||
self, part_number: str, from_rev: int, to_rev: int
|
||||
) -> Dict[str, Any]:
|
||||
def compare_revisions(self, part_number: str, from_rev: int, to_rev: int) -> Dict[str, Any]:
|
||||
"""Compare two revisions and return differences."""
|
||||
return self._request(
|
||||
"GET",
|
||||
@@ -578,9 +564,7 @@ class SiloClient:
|
||||
data = {}
|
||||
if comment:
|
||||
data["comment"] = comment
|
||||
return self._request(
|
||||
"POST", f"/items/{part_number}/revisions/{revision}/rollback", data
|
||||
)
|
||||
return self._request("POST", f"/items/{part_number}/revisions/{revision}/rollback", data)
|
||||
|
||||
def update_revision(
|
||||
self, part_number: str, revision: int, status: str = None, labels: list = None
|
||||
@@ -591,9 +575,7 @@ class SiloClient:
|
||||
data["status"] = status
|
||||
if labels is not None:
|
||||
data["labels"] = labels
|
||||
return self._request(
|
||||
"PATCH", f"/items/{part_number}/revisions/{revision}", data
|
||||
)
|
||||
return self._request("PATCH", f"/items/{part_number}/revisions/{revision}", data)
|
||||
|
||||
# BOM / Relationship methods
|
||||
|
||||
@@ -694,9 +676,7 @@ class SiloClient:
|
||||
|
||||
# Step 1: POST form-encoded credentials to /login
|
||||
login_url = f"{origin}/login"
|
||||
form_data = urllib.parse.urlencode(
|
||||
{"username": username, "password": password}
|
||||
).encode()
|
||||
form_data = urllib.parse.urlencode({"username": username, "password": password}).encode()
|
||||
req = urllib.request.Request(
|
||||
login_url,
|
||||
data=form_data,
|
||||
@@ -733,9 +713,7 @@ class SiloClient:
|
||||
import socket
|
||||
|
||||
hostname = socket.gethostname()
|
||||
token_body = json.dumps(
|
||||
{"name": f"FreeCAD ({hostname})", "expires_in_days": 90}
|
||||
).encode()
|
||||
token_body = json.dumps({"name": f"FreeCAD ({hostname})", "expires_in_days": 90}).encode()
|
||||
token_req = urllib.request.Request(
|
||||
token_url,
|
||||
data=token_body,
|
||||
@@ -819,9 +797,7 @@ class SiloClient:
|
||||
"""List API tokens for the current user."""
|
||||
return self._request("GET", "/auth/tokens")
|
||||
|
||||
def create_token(
|
||||
self, name: str, expires_in_days: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
def create_token(self, name: str, expires_in_days: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Create a new API token.
|
||||
|
||||
Returns dict with 'token' (raw, shown once), 'id', 'name', etc.
|
||||
@@ -856,9 +832,7 @@ class SiloClient:
|
||||
url = f"{origin}/health"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
req, context=_get_ssl_context(), timeout=5
|
||||
) as resp:
|
||||
with urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5) as resp:
|
||||
return True, f"OK ({resp.status})"
|
||||
except urllib.error.HTTPError as e:
|
||||
return True, f"Server error ({e.code})"
|
||||
@@ -1138,6 +1112,7 @@ class SiloSync:
|
||||
set_silo_properties(
|
||||
assembly_obj,
|
||||
{
|
||||
"SiloItemId": item.get("id", ""),
|
||||
"SiloPartNumber": part_number,
|
||||
"SiloRevision": item.get("current_revision", 1),
|
||||
"SiloItemType": item_type,
|
||||
@@ -1153,6 +1128,7 @@ class SiloSync:
|
||||
set_silo_properties(
|
||||
part_obj,
|
||||
{
|
||||
"SiloItemId": item.get("id", ""),
|
||||
"SiloPartNumber": part_number,
|
||||
"SiloRevision": item.get("current_revision", 1),
|
||||
"SiloItemType": item_type,
|
||||
@@ -1166,6 +1142,7 @@ class SiloSync:
|
||||
set_silo_properties(
|
||||
part_obj,
|
||||
{
|
||||
"SiloItemId": item.get("id", ""),
|
||||
"SiloPartNumber": part_number,
|
||||
"SiloRevision": item.get("current_revision", 1),
|
||||
"SiloItemType": item_type,
|
||||
@@ -1325,11 +1302,7 @@ class Silo_Open:
|
||||
try:
|
||||
for item in search_local_files(search_term):
|
||||
existing = next(
|
||||
(
|
||||
r
|
||||
for r in results_data
|
||||
if r["part_number"] == item["part_number"]
|
||||
),
|
||||
(r for r in results_data if r["part_number"] == item["part_number"]),
|
||||
None,
|
||||
)
|
||||
if existing:
|
||||
@@ -1353,12 +1326,8 @@ class Silo_Open:
|
||||
|
||||
results_table.setRowCount(len(results_data))
|
||||
for row, data in enumerate(results_data):
|
||||
results_table.setItem(
|
||||
row, 0, QtGui.QTableWidgetItem(data["part_number"])
|
||||
)
|
||||
results_table.setItem(
|
||||
row, 1, QtGui.QTableWidgetItem(data["description"])
|
||||
)
|
||||
results_table.setItem(row, 0, QtGui.QTableWidgetItem(data["part_number"]))
|
||||
results_table.setItem(row, 1, QtGui.QTableWidgetItem(data["description"]))
|
||||
results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"]))
|
||||
results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"]))
|
||||
results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"]))
|
||||
@@ -1424,13 +1393,9 @@ class Silo_New:
|
||||
try:
|
||||
schema = _client.get_schema()
|
||||
categories = schema.get("segments", [])
|
||||
cat_segment = next(
|
||||
(s for s in categories if s.get("name") == "category"), None
|
||||
)
|
||||
cat_segment = next((s for s in categories if s.get("name") == "category"), None)
|
||||
if cat_segment and cat_segment.get("values"):
|
||||
cat_list = [
|
||||
f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())
|
||||
]
|
||||
cat_list = [f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())]
|
||||
category_str, ok = QtGui.QInputDialog.getItem(
|
||||
None, "New Item", "Category:", cat_list, 0, False
|
||||
)
|
||||
@@ -1438,15 +1403,11 @@ class Silo_New:
|
||||
return
|
||||
category = category_str.split(" - ")[0]
|
||||
else:
|
||||
category, ok = QtGui.QInputDialog.getText(
|
||||
None, "New Item", "Category code:"
|
||||
)
|
||||
category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:")
|
||||
if not ok:
|
||||
return
|
||||
except Exception:
|
||||
category, ok = QtGui.QInputDialog.getText(
|
||||
None, "New Item", "Category code:"
|
||||
)
|
||||
category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:")
|
||||
if not ok:
|
||||
return
|
||||
|
||||
@@ -1491,9 +1452,7 @@ class Silo_New:
|
||||
ok_btn.clicked.connect(dialog.accept)
|
||||
|
||||
if dialog.exec_() == QtGui.QDialog.Accepted:
|
||||
selected_projects = [
|
||||
item.text() for item in list_widget.selectedItems()
|
||||
]
|
||||
selected_projects = [item.text() for item in list_widget.selectedItems()]
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n")
|
||||
|
||||
@@ -1510,7 +1469,12 @@ class Silo_New:
|
||||
# Tag selected object
|
||||
obj = sel[0]
|
||||
set_silo_properties(
|
||||
obj, {"SiloPartNumber": part_number, "SiloRevision": 1}
|
||||
obj,
|
||||
{
|
||||
"SiloItemId": result.get("id", ""),
|
||||
"SiloPartNumber": part_number,
|
||||
"SiloRevision": 1,
|
||||
},
|
||||
)
|
||||
obj.Label = part_number
|
||||
_sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True)
|
||||
@@ -1564,9 +1528,7 @@ class Silo_Save:
|
||||
# Check if document has unsaved changes
|
||||
gui_doc = FreeCADGui.getDocument(doc.Name)
|
||||
is_modified = gui_doc.Modified if gui_doc else True
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"[DEBUG] Modified={is_modified}, FileName={doc.FileName}\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"[DEBUG] Modified={is_modified}, FileName={doc.FileName}\n")
|
||||
|
||||
if gui_doc and not is_modified and doc.FileName:
|
||||
FreeCAD.Console.PrintMessage("No changes to save.\n")
|
||||
@@ -1597,15 +1559,11 @@ class Silo_Save:
|
||||
|
||||
# Check modified state after save
|
||||
is_modified_after_save = gui_doc.Modified if gui_doc else True
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"[DEBUG] After save: Modified={is_modified_after_save}\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"[DEBUG] After save: Modified={is_modified_after_save}\n")
|
||||
|
||||
# Force clear modified flag if save succeeded (needed for assemblies)
|
||||
if is_modified_after_save and gui_doc:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
"[DEBUG] Attempting to clear Modified flag...\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage("[DEBUG] Attempting to clear Modified flag...\n")
|
||||
try:
|
||||
gui_doc.Modified = False
|
||||
FreeCAD.Console.PrintMessage(
|
||||
@@ -1618,9 +1576,7 @@ class Silo_Save:
|
||||
|
||||
# Try to upload to MinIO
|
||||
try:
|
||||
result = _client._upload_file(
|
||||
part_number, str(file_path), properties, "Auto-save"
|
||||
)
|
||||
result = _client._upload_file(part_number, str(file_path), properties, "Auto-save")
|
||||
|
||||
new_rev = result["revision_number"]
|
||||
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
|
||||
@@ -1659,9 +1615,7 @@ class Silo_Commit:
|
||||
|
||||
obj = get_tracked_object(doc)
|
||||
if not obj:
|
||||
FreeCAD.Console.PrintError(
|
||||
"No tracked object. Use 'New' to register first.\n"
|
||||
)
|
||||
FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n")
|
||||
return
|
||||
|
||||
part_number = obj.SiloPartNumber
|
||||
@@ -1678,9 +1632,7 @@ class Silo_Commit:
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
result = _client._upload_file(
|
||||
part_number, str(file_path), properties, comment
|
||||
)
|
||||
result = _client._upload_file(part_number, str(file_path), properties, comment)
|
||||
|
||||
new_rev = result["revision_number"]
|
||||
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
|
||||
@@ -1727,9 +1679,7 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
|
||||
server_updated = item.get("updated_at", "")
|
||||
if server_updated:
|
||||
# Parse ISO format timestamp
|
||||
server_dt = datetime.datetime.fromisoformat(
|
||||
server_updated.replace("Z", "+00:00")
|
||||
)
|
||||
server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00"))
|
||||
if server_dt > local_mtime:
|
||||
conflicts.append("Server version is newer than local file.")
|
||||
except Exception:
|
||||
@@ -1759,9 +1709,7 @@ class SiloPullDialog:
|
||||
# Revision table
|
||||
self._table = QtGui.QTableWidget()
|
||||
self._table.setColumnCount(5)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["Rev", "Date", "Comment", "Status", "File"]
|
||||
)
|
||||
self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"])
|
||||
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
@@ -1875,18 +1823,14 @@ class Silo_Pull:
|
||||
|
||||
if not has_any_file:
|
||||
if existing_local:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Opening existing local file: {existing_local}\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n")
|
||||
FreeCAD.openDocument(str(existing_local))
|
||||
else:
|
||||
try:
|
||||
item = _client.get_item(part_number)
|
||||
new_doc = _sync.create_document_for_item(item, save=True)
|
||||
if new_doc:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Created local file for {part_number}\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n")
|
||||
else:
|
||||
QtGui.QMessageBox.warning(
|
||||
None,
|
||||
@@ -2017,9 +1961,7 @@ class Silo_Push:
|
||||
server_dt = datetime.fromisoformat(
|
||||
server_time_str.replace("Z", "+00:00")
|
||||
)
|
||||
local_dt = datetime.fromtimestamp(
|
||||
local_mtime, tz=timezone.utc
|
||||
)
|
||||
local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc)
|
||||
if local_dt > server_dt:
|
||||
unuploaded.append(lf)
|
||||
else:
|
||||
@@ -2032,9 +1974,7 @@ class Silo_Push:
|
||||
pass # Not in DB, skip
|
||||
|
||||
if not unuploaded:
|
||||
QtGui.QMessageBox.information(
|
||||
None, "Push", "All local files are already uploaded."
|
||||
)
|
||||
QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.")
|
||||
return
|
||||
|
||||
msg = f"Found {len(unuploaded)} files to upload:\n\n"
|
||||
@@ -2052,9 +1992,7 @@ class Silo_Push:
|
||||
|
||||
uploaded = 0
|
||||
for item in unuploaded:
|
||||
result = _sync.upload_file(
|
||||
item["part_number"], item["path"], "Synced from local"
|
||||
)
|
||||
result = _sync.upload_file(item["part_number"], item["path"], "Synced from local")
|
||||
if result:
|
||||
uploaded += 1
|
||||
|
||||
@@ -2103,7 +2041,9 @@ class Silo_Info:
|
||||
msg = f"<h3>{part_number}</h3>"
|
||||
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
|
||||
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
|
||||
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
|
||||
msg += (
|
||||
f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
|
||||
)
|
||||
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
|
||||
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
|
||||
|
||||
@@ -2169,9 +2109,7 @@ class Silo_TagProjects:
|
||||
try:
|
||||
# Get current projects for item
|
||||
current_projects = _client.get_item_projects(part_number)
|
||||
current_codes = {
|
||||
p.get("code", "") for p in current_projects if p.get("code")
|
||||
}
|
||||
current_codes = {p.get("code", "") for p in current_projects if p.get("code")}
|
||||
|
||||
# Get all available projects
|
||||
all_projects = _client.get_projects()
|
||||
@@ -2282,9 +2220,7 @@ class Silo_Rollback:
|
||||
dialog.setMinimumHeight(300)
|
||||
layout = QtGui.QVBoxLayout(dialog)
|
||||
|
||||
label = QtGui.QLabel(
|
||||
f"Select a revision to rollback to (current: Rev {current_rev}):"
|
||||
)
|
||||
label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):")
|
||||
layout.addWidget(label)
|
||||
|
||||
# Revision table
|
||||
@@ -2299,12 +2235,8 @@ class Silo_Rollback:
|
||||
for i, rev in enumerate(prev_revisions):
|
||||
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
|
||||
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
|
||||
table.setItem(
|
||||
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])
|
||||
)
|
||||
table.setItem(
|
||||
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
|
||||
)
|
||||
table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]))
|
||||
table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or ""))
|
||||
|
||||
table.resizeColumnsToContents()
|
||||
layout.addWidget(table)
|
||||
@@ -2330,9 +2262,7 @@ class Silo_Rollback:
|
||||
def on_rollback():
|
||||
selected = table.selectedItems()
|
||||
if not selected:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "Rollback", "Please select a revision"
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision")
|
||||
return
|
||||
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
|
||||
dialog.accept()
|
||||
@@ -2430,9 +2360,7 @@ class Silo_SetStatus:
|
||||
# Update status
|
||||
_client.update_revision(part_number, rev_num, status=status)
|
||||
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Updated Rev {rev_num} status to '{status}'\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n")
|
||||
QtGui.QMessageBox.information(
|
||||
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
|
||||
)
|
||||
@@ -2496,9 +2424,7 @@ class Silo_Settings:
|
||||
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
|
||||
layout.addWidget(ssl_checkbox)
|
||||
|
||||
ssl_hint = QtGui.QLabel(
|
||||
"Disable only for internal servers with self-signed certificates."
|
||||
)
|
||||
ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.")
|
||||
ssl_hint.setWordWrap(True)
|
||||
ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
|
||||
layout.addWidget(ssl_hint)
|
||||
@@ -2521,8 +2447,7 @@ class Silo_Settings:
|
||||
layout.addLayout(cert_row)
|
||||
|
||||
cert_hint = QtGui.QLabel(
|
||||
"Path to a PEM/CRT file for internal CAs. "
|
||||
"Leave empty for system certificates only."
|
||||
"Path to a PEM/CRT file for internal CAs. Leave empty for system certificates only."
|
||||
)
|
||||
cert_hint.setWordWrap(True)
|
||||
cert_hint.setStyleSheet("color: #888; font-size: 11px;")
|
||||
@@ -2712,8 +2637,7 @@ class Silo_BOM:
|
||||
_qg.QMessageBox.warning(
|
||||
None,
|
||||
"BOM",
|
||||
"This document is not registered with Silo.\n"
|
||||
"Use Silo > New to register it first.",
|
||||
"This document is not registered with Silo.\nUse Silo > New to register it first.",
|
||||
)
|
||||
return
|
||||
|
||||
@@ -2774,9 +2698,7 @@ class Silo_BOM:
|
||||
|
||||
wu_table = QtGui.QTableWidget()
|
||||
wu_table.setColumnCount(5)
|
||||
wu_table.setHorizontalHeaderLabels(
|
||||
["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
|
||||
)
|
||||
wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"])
|
||||
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
@@ -2805,16 +2727,12 @@ class Silo_BOM:
|
||||
bom_table.setItem(
|
||||
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
|
||||
)
|
||||
bom_table.setItem(
|
||||
row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
|
||||
)
|
||||
bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
|
||||
qty = entry.get("quantity")
|
||||
bom_table.setItem(
|
||||
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
||||
)
|
||||
bom_table.setItem(
|
||||
row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
|
||||
)
|
||||
bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or ""))
|
||||
ref_des = entry.get("reference_designators") or []
|
||||
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
||||
bom_table.setItem(
|
||||
@@ -2836,16 +2754,12 @@ class Silo_BOM:
|
||||
wu_table.setItem(
|
||||
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
|
||||
)
|
||||
wu_table.setItem(
|
||||
row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
|
||||
)
|
||||
wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
|
||||
qty = entry.get("quantity")
|
||||
wu_table.setItem(
|
||||
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
||||
)
|
||||
wu_table.setItem(
|
||||
row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
|
||||
)
|
||||
wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or ""))
|
||||
ref_des = entry.get("reference_designators") or []
|
||||
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
||||
wu_table.resizeColumnsToContents()
|
||||
@@ -2898,9 +2812,7 @@ class Silo_BOM:
|
||||
try:
|
||||
qty = float(qty_text)
|
||||
except ValueError:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "BOM", "Quantity must be a number."
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
|
||||
return
|
||||
|
||||
unit = unit_input.text().strip() or None
|
||||
@@ -2979,9 +2891,7 @@ class Silo_BOM:
|
||||
try:
|
||||
new_qty = float(qty_text)
|
||||
except ValueError:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "BOM", "Quantity must be a number."
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
|
||||
return
|
||||
|
||||
new_unit = unit_input.text().strip() or None
|
||||
@@ -3005,9 +2915,7 @@ class Silo_BOM:
|
||||
)
|
||||
load_bom()
|
||||
except Exception as exc:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "BOM", f"Failed to update entry:\n{exc}"
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}")
|
||||
|
||||
def on_remove():
|
||||
selected = bom_table.selectedItems()
|
||||
@@ -3033,9 +2941,7 @@ class Silo_BOM:
|
||||
_client.delete_bom_entry(part_number, child_pn)
|
||||
load_bom()
|
||||
except Exception as exc:
|
||||
QtGui.QMessageBox.warning(
|
||||
dialog, "BOM", f"Failed to remove entry:\n{exc}"
|
||||
)
|
||||
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}")
|
||||
|
||||
add_btn.clicked.connect(on_add)
|
||||
edit_btn.clicked.connect(on_edit)
|
||||
@@ -3153,9 +3059,7 @@ class SiloEventListener(QtCore.QThread):
|
||||
|
||||
item_updated = QtCore.Signal(str) # part_number
|
||||
revision_created = QtCore.Signal(str, int) # part_number, revision
|
||||
connection_status = QtCore.Signal(
|
||||
str
|
||||
) # "connected" / "disconnected" / "unsupported"
|
||||
connection_status = QtCore.Signal(str) # "connected" / "disconnected" / "unsupported"
|
||||
|
||||
_MAX_FAST_RETRIES = 3
|
||||
_FAST_RETRY_SECS = 5
|
||||
@@ -3217,9 +3121,7 @@ class SiloEventListener(QtCore.QThread):
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
|
||||
try:
|
||||
self._response = urllib.request.urlopen(
|
||||
req, context=_get_ssl_context(), timeout=90
|
||||
)
|
||||
self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code in (404, 501):
|
||||
raise _SSEUnsupported()
|
||||
@@ -3490,14 +3392,10 @@ class SiloAuthDockWidget:
|
||||
self._refresh_activity_panel()
|
||||
|
||||
def _on_remote_revision(self, part_number, revision):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: New revision {revision} for {part_number}\n"
|
||||
)
|
||||
FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n")
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is not None:
|
||||
mw.statusBar().showMessage(
|
||||
f"Silo: {part_number} rev {revision} available", 5000
|
||||
)
|
||||
mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000)
|
||||
self._refresh_activity_panel()
|
||||
|
||||
def _refresh_activity_panel(self):
|
||||
|
||||
546
pkg/freecad/silo_origin.py
Normal file
546
pkg/freecad/silo_origin.py
Normal file
@@ -0,0 +1,546 @@
|
||||
"""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 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
|
||||
|
||||
# =========================================================================
|
||||
# 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
|
||||
Reference in New Issue
Block a user