From 264e82179dc6bddc831bad7f48b65ebbbf155b26 Mon Sep 17 00:00:00 2001 From: forbes Date: Wed, 18 Feb 2026 19:36:04 -0600 Subject: [PATCH] feat(create): server integration for silo viewer widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire live data fetching, SSE subscriptions, and server write-back into the History, Metadata, and Dependency viewer widgets. Changes: - Add server integration helpers (_init_server, _is_online, _get_part_number, offline banner) with lazy silo_commands import - SiloHistoryViewer: Refresh button fetches live revisions via SiloClient.get_revisions(); SSE revision_created auto-refreshes - SiloMetadataEditor: Save pushes to server (update_metadata, patch_lifecycle, patch_tags); SSE item_updated refreshes form when no local edits pending; offline banner - SiloDependencyTable: Server-side UUID resolution via resolve_dependencies(); Download button for unresolved items; Refresh re-checks status; three-state icons (resolved/ downloadable/missing) - All viewers show 'Offline — showing cached data' banner when disconnected and disable server-dependent controls Bump silo submodule to track new silo-client API methods: get_metadata, update_metadata, patch_lifecycle, patch_tags, resolve_dependencies (silo-client PR #19) Closes kindred/silo-mod#43 --- mods/silo | 2 +- src/Mod/Create/silo_viewers.py | 405 +++++++++++++++++++++++++++++---- 2 files changed, 367 insertions(+), 40 deletions(-) diff --git a/mods/silo b/mods/silo index dca6380199..edbaf65923 160000 --- a/mods/silo +++ b/mods/silo @@ -1 +1 @@ -Subproject commit dca63801992413327f8b2ebd0aff4657fcf5539e +Subproject commit edbaf65923eac1567908a9d8c50992dff7f2536d diff --git a/src/Mod/Create/silo_viewers.py b/src/Mod/Create/silo_viewers.py index 4f40f3b9b5..ccbc967a7e 100644 --- a/src/Mod/Create/silo_viewers.py +++ b/src/Mod/Create/silo_viewers.py @@ -12,6 +12,63 @@ import json import FreeCAD from PySide import QtCore, QtWidgets +# --------------------------------------------------------------------------- +# Server integration — optional, degrades gracefully offline +# --------------------------------------------------------------------------- + +_silo_client = None # SiloClient instance or None +_silo_server_mode = None # callable returning "normal"/"read-only"/"offline" + + +def _init_server(): + """Lazy-import silo_commands and cache client + mode accessor.""" + global _silo_client, _silo_server_mode + if _silo_server_mode is not None: + return + try: + import silo_commands + + _silo_client = silo_commands._client + _silo_server_mode = lambda: silo_commands._server_mode + except ImportError: + _silo_server_mode = lambda: "offline" + + +def _is_online(): + """Check if silo server is reachable.""" + _init_server() + return _silo_server_mode() == "normal" + + +def _get_part_number(doc=None): + """Get the Silo part number from the document's tracked object.""" + if doc is None: + doc = FreeCAD.ActiveDocument + if doc is None: + return None + for obj in doc.Objects: + if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber: + return obj.SiloPartNumber + return None + + +def _make_offline_banner(): + """Create a hidden banner label for offline state.""" + banner = QtWidgets.QLabel("Offline \u2014 showing cached data") + banner.setStyleSheet( + "QLabel { background: #f38ba8; color: #11111b; " + "padding: 4px 8px; border-radius: 4px; font-size: 11px; }" + ) + banner.setVisible(False) + return banner + + +def _update_banner(banner): + """Show banner if offline, hide if online.""" + _init_server() + banner.setVisible(_silo_server_mode() != "normal") + + # --------------------------------------------------------------------------- # Manifest Viewer # --------------------------------------------------------------------------- @@ -133,7 +190,7 @@ _LIFECYCLE_OPTIONS = ["draft", "review", "released", "obsolete"] class SiloMetadataEditor(QtWidgets.QWidget): - """Editable form for ``silo/metadata.json`` fields.""" + """Editable form for ``silo/metadata.json`` with server write-back.""" WINDOW_TITLE = "Silo \u2014 Metadata" @@ -147,8 +204,11 @@ class SiloMetadataEditor(QtWidgets.QWidget): self._lifecycle_combo = None self._save_btn = None self._reset_btn = None + self._banner = None + self._event_listener = None self._schema_name = "" self._build_ui(obj.RawContent) + self._start_sse() # -- layout -------------------------------------------------------------- @@ -185,6 +245,11 @@ class SiloMetadataEditor(QtWidgets.QWidget): header.addWidget(self._lifecycle_combo) outer.addLayout(header) + # Offline banner + self._banner = _make_offline_banner() + _update_banner(self._banner) + outer.addWidget(self._banner) + # Separator line = QtWidgets.QFrame() line.setFrameShape(QtWidgets.QFrame.HLine) @@ -354,7 +419,7 @@ class SiloMetadataEditor(QtWidgets.QWidget): } def _on_save(self): - """Write current form state to obj.RawContent.""" + """Write current form state to obj.RawContent and push to server.""" new_data = self._collect_data() self._obj.RawContent = json.dumps(new_data, indent=2) self._obj.Proxy.clear_dirty() @@ -362,6 +427,26 @@ class SiloMetadataEditor(QtWidgets.QWidget): self._save_btn.setEnabled(False) self._reset_btn.setEnabled(False) + # Server write-back (best-effort) + pn = _get_part_number() + if pn and _is_online(): + self._push_to_server(pn, new_data) + + def _push_to_server(self, pn, data): + """Push metadata changes to server. Failures are non-fatal.""" + try: + _silo_client.update_metadata(pn, {"fields": data.get("fields", {})}) + except Exception as exc: + FreeCAD.Console.PrintWarning(f"Metadata push failed: {exc}\n") + try: + _silo_client.patch_lifecycle(pn, data.get("lifecycle", "draft")) + except Exception as exc: + FreeCAD.Console.PrintWarning(f"Lifecycle push failed: {exc}\n") + try: + _silo_client.patch_tags(pn, data.get("tags", [])) + except Exception as exc: + FreeCAD.Console.PrintWarning(f"Tags push failed: {exc}\n") + def _on_reset(self): """Revert all fields to last-saved state.""" # Clear existing field widgets and tags @@ -405,9 +490,88 @@ class SiloMetadataEditor(QtWidgets.QWidget): self._save_btn.setEnabled(False) self._reset_btn.setEnabled(False) + # -- server integration -------------------------------------------------- + + def _rebuild_form(self, data): + """Rebuild lifecycle, tags, and fields from a data dict.""" + self._schema_name = data.get("schema", self._schema_name) + + # Lifecycle + lc = data.get("lifecycle", "draft") + idx = self._lifecycle_combo.findText(lc) + if idx >= 0: + self._lifecycle_combo.blockSignals(True) + self._lifecycle_combo.setCurrentIndex(idx) + self._lifecycle_combo.blockSignals(False) + + # Tags — clear and rebuild + for i in reversed(range(self._tags_layout.count())): + w = self._tags_layout.itemAt(i).widget() + if w: + w.deleteLater() + for tag in data.get("tags", []): + self._add_tag_chip(tag) + + # Fields — find QGroupBox and rebuild + self._field_widgets.clear() + for child in self.findChildren(QtWidgets.QGroupBox): + if child.title() == "Fields": + form = child.layout() + while form.count(): + item = form.takeAt(0) + if item.widget(): + item.widget().deleteLater() + for key, value in data.get("fields", {}).items(): + widget = self._make_field_widget(value) + self._field_widgets[key] = widget + form.addRow(key + ":", widget) + break + + def _start_sse(self): + if not _is_online(): + return + try: + import silo_commands + + self._event_listener = silo_commands.SiloEventListener() + self._event_listener.item_updated.connect(self._on_sse_item_updated) + self._event_listener.connection_status.connect(self._on_sse_status) + self._event_listener.start() + except Exception: + pass + + def _on_sse_item_updated(self, part_number): + pn = _get_part_number() + if not pn or pn != part_number: + return + if self._obj.Proxy.is_dirty(): + FreeCAD.Console.PrintWarning( + "Metadata: remote update received but local edits exist. " + "Save or reset to sync.\n" + ) + return + try: + server_data = _silo_client.get_metadata(pn) + local_data = { + "schema": server_data.get("schema_name", self._schema_name), + "lifecycle": server_data.get("lifecycle_state", "draft"), + "tags": server_data.get("tags", []), + "fields": server_data.get("fields", {}), + } + self._obj.RawContent = json.dumps(local_data, indent=2) + self._original_data = copy.deepcopy(local_data) + self._rebuild_form(local_data) + except Exception as exc: + FreeCAD.Console.PrintWarning(f"Metadata SSE refresh failed: {exc}\n") + + def _on_sse_status(self, status, retry_count, error_msg): + _update_banner(self._banner) + # -- close guard --------------------------------------------------------- def closeEvent(self, event): + if self._event_listener is not None: + self._event_listener.stop() if self._obj.Proxy.is_dirty(): reply = QtWidgets.QMessageBox.question( self, @@ -436,14 +600,20 @@ _BADGE_STYLES = { class SiloHistoryViewer(QtWidgets.QWidget): - """Read-only revision timeline from ``silo/history.json``.""" + """Revision timeline from ``silo/history.json`` with live refresh.""" WINDOW_TITLE = "Silo \u2014 History" def __init__(self, obj, parent=None): super().__init__(parent) self.setObjectName(f"SiloViewer_{obj.Name}") + self._obj = obj + self._cards_layout = None + self._banner = None + self._refresh_btn = None + self._event_listener = None self._build_ui(obj.RawContent) + self._start_sse() def _build_ui(self, raw_content): try: @@ -465,42 +635,112 @@ class SiloHistoryViewer(QtWidgets.QWidget): title.setFont(font) header.addWidget(title) header.addStretch() + self._refresh_btn = QtWidgets.QPushButton("Refresh") + self._refresh_btn.setEnabled(_is_online()) + self._refresh_btn.clicked.connect(self._on_refresh) + header.addWidget(self._refresh_btn) outer.addLayout(header) + # Offline banner + self._banner = _make_offline_banner() + _update_banner(self._banner) + outer.addWidget(self._banner) + # Separator line = QtWidgets.QFrame() line.setFrameShape(QtWidgets.QFrame.HLine) line.setFrameShadow(QtWidgets.QFrame.Sunken) outer.addWidget(line) - if not revisions: - placeholder = QtWidgets.QLabel("No revision history available.") - placeholder.setAlignment(QtCore.Qt.AlignCenter) - outer.addWidget(placeholder) - outer.addStretch() - return - # Scroll area with revision cards scroll = QtWidgets.QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QtWidgets.QFrame.NoFrame) content = QtWidgets.QWidget() - cards_layout = QtWidgets.QVBoxLayout(content) - cards_layout.setContentsMargins(0, 0, 0, 0) - cards_layout.setSpacing(0) + self._cards_layout = QtWidgets.QVBoxLayout(content) + self._cards_layout.setContentsMargins(0, 0, 0, 0) + self._cards_layout.setSpacing(0) + + if not revisions: + placeholder = QtWidgets.QLabel("No revision history available.") + placeholder.setAlignment(QtCore.Qt.AlignCenter) + self._cards_layout.addWidget(placeholder) for i, rev in enumerate(revisions): - cards_layout.addWidget(self._make_revision_card(rev)) + self._cards_layout.addWidget(self._make_revision_card(rev)) if i < len(revisions) - 1: sep = QtWidgets.QFrame() sep.setFrameShape(QtWidgets.QFrame.HLine) sep.setFrameShadow(QtWidgets.QFrame.Sunken) - cards_layout.addWidget(sep) + self._cards_layout.addWidget(sep) - cards_layout.addStretch() + self._cards_layout.addStretch() scroll.setWidget(content) outer.addWidget(scroll, 1) + # -- server integration -------------------------------------------------- + + def _on_refresh(self): + pn = _get_part_number() + if not pn or not _is_online(): + return + try: + revisions = _silo_client.get_revisions(pn) + except Exception as exc: + FreeCAD.Console.PrintWarning(f"History refresh failed: {exc}\n") + return + data = {"revisions": revisions} + self._obj.RawContent = json.dumps(data, indent=2) + self._obj.Proxy.mark_dirty() + self._rebuild_cards(revisions) + + def _rebuild_cards(self, revisions): + """Clear and rebuild revision cards.""" + while self._cards_layout.count(): + item = self._cards_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + if not revisions: + placeholder = QtWidgets.QLabel("No revision history available.") + placeholder.setAlignment(QtCore.Qt.AlignCenter) + self._cards_layout.addWidget(placeholder) + else: + for i, rev in enumerate(revisions): + self._cards_layout.addWidget(self._make_revision_card(rev)) + if i < len(revisions) - 1: + sep = QtWidgets.QFrame() + sep.setFrameShape(QtWidgets.QFrame.HLine) + sep.setFrameShadow(QtWidgets.QFrame.Sunken) + self._cards_layout.addWidget(sep) + self._cards_layout.addStretch() + + def _start_sse(self): + if not _is_online(): + return + try: + import silo_commands + + self._event_listener = silo_commands.SiloEventListener() + self._event_listener.revision_created.connect(self._on_sse_revision) + self._event_listener.connection_status.connect(self._on_sse_status) + self._event_listener.start() + except Exception: + pass + + def _on_sse_revision(self, part_number, revision): + pn = _get_part_number() + if pn and pn == part_number: + self._on_refresh() + + def _on_sse_status(self, status, retry_count, error_msg): + _update_banner(self._banner) + self._refresh_btn.setEnabled(_is_online()) + + def closeEvent(self, event): + if self._event_listener is not None: + self._event_listener.stop() + event.accept() + def _make_revision_card(self, rev): """Build a widget for a single revision entry.""" card = QtWidgets.QWidget() @@ -697,13 +937,18 @@ def _open_url(url): class SiloDependencyTable(QtWidgets.QWidget): - """Table view of assembly dependencies from ``silo/dependencies.json``.""" + """Table view of assembly dependencies with server resolution.""" WINDOW_TITLE = "Silo \u2014 Dependencies" def __init__(self, obj, parent=None): super().__init__(parent) self.setObjectName(f"SiloViewer_{obj.Name}") + self._obj = obj + self._links = [] + self._model = None + self._table = None + self._banner = None self._build_ui(obj.RawContent) def _build_ui(self, raw_content): @@ -711,65 +956,147 @@ class SiloDependencyTable(QtWidgets.QWidget): data = json.loads(raw_content) except Exception: data = {} - links = data.get("links", []) + self._links = data.get("links", []) outer = QtWidgets.QVBoxLayout(self) outer.setContentsMargins(16, 16, 16, 16) outer.setSpacing(12) - # Header + # Header with buttons + header = QtWidgets.QHBoxLayout() title = QtWidgets.QLabel("Assembly Dependencies") font = title.font() font.setPointSize(font.pointSize() + 2) font.setBold(True) title.setFont(font) - outer.addWidget(title) + header.addWidget(title) + header.addStretch() + + self._refresh_btn = QtWidgets.QPushButton("Refresh") + self._refresh_btn.setEnabled(_is_online()) + self._refresh_btn.clicked.connect(self._on_refresh) + header.addWidget(self._refresh_btn) + + self._download_btn = QtWidgets.QPushButton("Download") + self._download_btn.setEnabled(_is_online()) + self._download_btn.setToolTip("Download selected unresolved dependencies") + self._download_btn.clicked.connect(self._on_download) + header.addWidget(self._download_btn) + outer.addLayout(header) + + # Offline banner + self._banner = _make_offline_banner() + _update_banner(self._banner) + outer.addWidget(self._banner) sep = QtWidgets.QFrame() sep.setFrameShape(QtWidgets.QFrame.HLine) sep.setFrameShadow(QtWidgets.QFrame.Sunken) outer.addWidget(sep) - if not links: + if not self._links: outer.addWidget(QtWidgets.QLabel("No dependencies defined.")) outer.addStretch() return # Table - from PySide.QtGui import QStandardItemModel, QStandardItem + from PySide.QtGui import QStandardItem, QStandardItemModel - table = QtWidgets.QTableView() - model = QStandardItemModel(len(links), 5) - model.setHorizontalHeaderLabels(["Label", "Part Number", "Rev", "Qty", "Local"]) + self._table = QtWidgets.QTableView() + self._model = QStandardItemModel(len(self._links), 5) + self._model.setHorizontalHeaderLabels( + ["Label", "Part Number", "Rev", "Qty", "Status"] + ) - for row, link in enumerate(links): + # Try server-side resolution first + server_map = self._resolve_via_server() + + for row, link in enumerate(self._links): label_item = QStandardItem(link.get("label", "")) label_item.setEditable(False) - model.setItem(row, 0, label_item) + self._model.setItem(row, 0, label_item) pn_item = QStandardItem(link.get("part_number", "")) pn_item.setEditable(False) - model.setItem(row, 1, pn_item) + self._model.setItem(row, 1, pn_item) rev_item = QStandardItem(str(link.get("revision", ""))) rev_item.setEditable(False) - model.setItem(row, 2, rev_item) + self._model.setItem(row, 2, rev_item) qty_item = QStandardItem(str(link.get("quantity", ""))) qty_item.setEditable(False) - model.setItem(row, 3, qty_item) + self._model.setItem(row, 3, qty_item) - # Resolution: check if UUID is in any open document - resolved = _is_dependency_resolved(link.get("uuid", "")) - status_item = QStandardItem("\u2713" if resolved else "\u2717") + uuid = link.get("uuid", "") + icon = self._status_icon(uuid, server_map) + status_item = QStandardItem(icon) status_item.setEditable(False) - model.setItem(row, 4, status_item) + self._model.setItem(row, 4, status_item) - table.setModel(model) - table.horizontalHeader().setStretchLastSection(True) - table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) - outer.addWidget(table, 1) + self._table.setModel(self._model) + self._table.horizontalHeader().setStretchLastSection(True) + self._table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self._table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + outer.addWidget(self._table, 1) + + # -- server integration -------------------------------------------------- + + def _resolve_via_server(self): + """Use server-side resolution if online, else return None.""" + pn = _get_part_number() + if pn and _is_online(): + try: + resolved = _silo_client.resolve_dependencies(pn) + return {r["uuid"]: r for r in resolved} + except Exception as exc: + FreeCAD.Console.PrintWarning(f"Dependency resolve failed: {exc}\n") + return None + + def _status_icon(self, uuid, server_map): + """Return status icon for a dependency UUID.""" + if server_map and uuid in server_map: + r = server_map[uuid] + if r.get("resolved", False): + return "\u2713" # check mark + if r.get("file_available", False): + return "\u2193" # down arrow — downloadable + return "\u2717" # cross mark + return "\u2713" if _is_dependency_resolved(uuid) else "\u2717" + + def _on_refresh(self): + """Re-resolve dependencies and update status column.""" + if self._model is None: + return + server_map = self._resolve_via_server() + for row, link in enumerate(self._links): + uuid = link.get("uuid", "") + icon = self._status_icon(uuid, server_map) + item = self._model.item(row, 4) + if item: + item.setText(icon) + + def _on_download(self): + """Download selected unresolved dependencies.""" + if self._table is None: + return + indexes = self._table.selectionModel().selectedRows() + if not indexes: + return + for idx in indexes: + pn_item = self._model.item(idx.row(), 1) + pn = pn_item.text() if pn_item else "" + if not pn: + continue + try: + import silo_commands + + path = silo_commands._sync.download_file(pn) + if path: + FreeCAD.Console.PrintMessage(f"Downloaded: {path}\n") + except Exception as exc: + FreeCAD.Console.PrintWarning(f"Download {pn} failed: {exc}\n") + self._on_refresh() def _is_dependency_resolved(uuid_str): -- 2.49.1