From 1478514b13cdffbe9ed1f2e2ff2a02d2339b2b3d Mon Sep 17 00:00:00 2001 From: Forbes Date: Thu, 5 Feb 2026 13:29:45 -0600 Subject: [PATCH] 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 --- pkg/freecad/InitGui.py | 12 +- pkg/freecad/silo_commands.py | 238 +++++---------- pkg/freecad/silo_origin.py | 546 +++++++++++++++++++++++++++++++++++ 3 files changed, 623 insertions(+), 173 deletions(-) create mode 100644 pkg/freecad/silo_origin.py diff --git a/pkg/freecad/InitGui.py b/pkg/freecad/InitGui.py index e63a98b..0a6d1b9 100644 --- a/pkg/freecad/InitGui.py +++ b/pkg/freecad/InitGui.py @@ -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) diff --git a/pkg/freecad/silo_commands.py b/pkg/freecad/silo_commands.py index 8b72b66..f46c73b 100644 --- a/pkg/freecad/silo_commands.py +++ b/pkg/freecad/silo_commands.py @@ -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"

{part_number}

" msg += f"

Type: {item.get('item_type', '-')}

" msg += f"

Description: {item.get('description', '-')}

" - msg += f"

Projects: {', '.join(project_codes) if project_codes else 'None'}

" + msg += ( + f"

Projects: {', '.join(project_codes) if project_codes else 'None'}

" + ) msg += f"

Current Revision: {item.get('current_revision', 1)}

" msg += f"

Local Revision: {getattr(obj, 'SiloRevision', '-')}

" @@ -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): diff --git a/pkg/freecad/silo_origin.py b/pkg/freecad/silo_origin.py new file mode 100644 index 0000000..29892d3 --- /dev/null +++ b/pkg/freecad/silo_origin.py @@ -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