diff --git a/pkg/freecad/silo_commands.py b/pkg/freecad/silo_commands.py index 0e465dd..8b72b66 100644 --- a/pkg/freecad/silo_commands.py +++ b/pkg/freecad/silo_commands.py @@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple import FreeCAD import FreeCADGui +from PySide import QtCore # Preference group for Kindred Silo settings _PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo" @@ -383,19 +384,35 @@ class SiloClient: except urllib.error.URLError as e: raise RuntimeError(f"Connection error: {e.reason}") - def _download_file(self, part_number: str, revision: int, dest_path: str) -> bool: - """Download a file from MinIO storage.""" + def _download_file( + self, + part_number: str, + revision: int, + dest_path: str, + progress_callback=None, + ) -> bool: + """Download a file from MinIO storage. + + Args: + progress_callback: Optional callable(bytes_downloaded, total_bytes). + total_bytes is -1 if the server did not send Content-Length. + """ url = f"{self.base_url}/items/{part_number}/file/{revision}" req = urllib.request.Request(url, headers=_get_auth_headers(), method="GET") try: with urllib.request.urlopen(req, context=_get_ssl_context()) as resp: + total = int(resp.headers.get("Content-Length", -1)) + downloaded = 0 with open(dest_path, "wb") as f: while True: chunk = resp.read(8192) if not chunk: break f.write(chunk) + downloaded += len(chunk) + if progress_callback is not None: + progress_callback(downloaded, total) return True except urllib.error.HTTPError as e: if e.code == 404: @@ -1675,13 +1692,154 @@ class Silo_Commit: return FreeCAD.ActiveDocument is not None +def _check_pull_conflicts(part_number, local_path, doc=None): + """Check for conflicts between local file and server state. + + Returns a list of conflict description strings, or an empty list if clean. + """ + conflicts = [] + + # Check for unsaved changes in an open document + if doc is not None and doc.IsModified(): + conflicts.append("Document has unsaved local changes.") + + # Check local revision vs server latest + if doc is not None: + obj = get_tracked_object(doc) + if obj and hasattr(obj, "SiloRevision"): + local_rev = getattr(obj, "SiloRevision", 0) + latest = _client.latest_file_revision(part_number) + if latest and local_rev and latest["revision_number"] > local_rev: + conflicts.append( + f"Local file is at revision {local_rev}, " + f"server has revision {latest['revision_number']}." + ) + + # Check local file mtime vs server timestamp + if local_path and local_path.exists(): + import datetime + + local_mtime = datetime.datetime.fromtimestamp( + local_path.stat().st_mtime, tz=datetime.timezone.utc + ) + try: + item = _client.get_item(part_number) + server_updated = item.get("updated_at", "") + if server_updated: + # Parse ISO format timestamp + 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: + pass + + return conflicts + + +class SiloPullDialog: + """Dialog for selecting which revision to pull.""" + + def __init__(self, part_number, revisions, parent=None): + from PySide import QtCore, QtGui + + self._selected_revision = None + + self._dialog = QtGui.QDialog(parent) + self._dialog.setWindowTitle(f"Pull - {part_number}") + self._dialog.setMinimumWidth(600) + self._dialog.setMinimumHeight(350) + + layout = QtGui.QVBoxLayout(self._dialog) + + info = QtGui.QLabel(f"Select a revision to download for {part_number}:") + layout.addWidget(info) + + # Revision table + self._table = QtGui.QTableWidget() + self._table.setColumnCount(5) + 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) + self._table.verticalHeader().setVisible(False) + header = self._table.horizontalHeader() + header.setStretchLastSection(True) + + # Populate rows + file_revisions = [] + self._table.setRowCount(len(revisions)) + for i, rev in enumerate(revisions): + rev_num = rev.get("revision_number", "") + date = rev.get("created_at", "")[:16].replace("T", " ") + comment = rev.get("comment", "") + status = rev.get("status", "") + has_file = "\u2713" if rev.get("file_key") else "" + + self._table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev_num))) + self._table.setItem(i, 1, QtGui.QTableWidgetItem(date)) + self._table.setItem(i, 2, QtGui.QTableWidgetItem(comment)) + self._table.setItem(i, 3, QtGui.QTableWidgetItem(status)) + file_item = QtGui.QTableWidgetItem(has_file) + file_item.setTextAlignment(QtCore.Qt.AlignCenter) + self._table.setItem(i, 4, file_item) + + if rev.get("file_key"): + file_revisions.append(i) + + self._table.resizeColumnsToContents() + layout.addWidget(self._table) + + # Pre-select the latest revision with a file + if file_revisions: + self._table.selectRow(file_revisions[0]) + + # Store revision data for lookup + self._revisions = revisions + + # Buttons + btn_layout = QtGui.QHBoxLayout() + btn_layout.addStretch() + download_btn = QtGui.QPushButton("Download") + cancel_btn = QtGui.QPushButton("Cancel") + download_btn.clicked.connect(self._on_download) + cancel_btn.clicked.connect(self._dialog.reject) + self._table.doubleClicked.connect(self._on_download) + btn_layout.addWidget(download_btn) + btn_layout.addWidget(cancel_btn) + layout.addLayout(btn_layout) + + def _on_download(self): + row = self._table.currentRow() + if row < 0: + return + rev = self._revisions[row] + if not rev.get("file_key"): + from PySide import QtGui + + QtGui.QMessageBox.information( + self._dialog, "Pull", "Selected revision has no file attached." + ) + return + self._selected_revision = rev + self._dialog.accept() + + def exec_(self): + if self._dialog.exec_() == 1: # QDialog.Accepted + return self._selected_revision + return None + + class Silo_Pull: """Download from MinIO / sync from database.""" def GetResources(self): return { "MenuText": "Pull", - "ToolTip": "Download latest from MinIO or create from database", + "ToolTip": "Download from MinIO with revision selection", "Pixmap": _icon("pull"), } @@ -1690,6 +1848,7 @@ class Silo_Pull: doc = FreeCAD.ActiveDocument part_number = None + obj = None if doc: obj = get_tracked_object(doc) @@ -1702,43 +1861,25 @@ class Silo_Pull: return part_number = part_number.strip().upper() - # Check if local file exists + # Fetch revisions from server + try: + revisions = _client.get_revisions(part_number) + except Exception as e: + QtGui.QMessageBox.warning(None, "Pull", f"Cannot reach server: {e}") + return + existing_local = find_file_by_part_number(part_number) - # Check if file exists in MinIO - has_file, rev_num = _client.has_file(part_number) + # If no revisions have files, fall back to create-from-database + has_any_file = any(r.get("file_key") for r in revisions) - if has_file: - # File exists in MinIO + if not has_any_file: if existing_local: - # Local file exists - ask before overwriting - reply = QtGui.QMessageBox.question( - None, - "Pull", - f"Download revision {rev_num} and overwrite local file?", - QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, - ) - if reply != QtGui.QMessageBox.Yes: - return - - # Download from MinIO (creates local file automatically) - downloaded = _sync.download_file(part_number) - if downloaded: - FreeCAD.Console.PrintMessage(f"Downloaded: {downloaded}\n") - # Automatically open the downloaded file - FreeCAD.openDocument(str(downloaded)) - else: - QtGui.QMessageBox.warning(None, "Pull", "Download failed") - else: - # No file in MinIO - create from database - if existing_local: - # Local file already exists, just open it FreeCAD.Console.PrintMessage( f"Opening existing local file: {existing_local}\n" ) FreeCAD.openDocument(str(existing_local)) else: - # No local file and no MinIO file - create new from DB try: item = _client.get_item(part_number) new_doc = _sync.create_document_for_item(item, save=True) @@ -1748,10 +1889,93 @@ class Silo_Pull: ) else: QtGui.QMessageBox.warning( - None, "Pull", f"Failed to create document for {part_number}" + None, + "Pull", + f"Failed to create document for {part_number}", ) except Exception as e: QtGui.QMessageBox.warning(None, "Pull", f"Failed: {e}") + return + + # Conflict detection + conflicts = _check_pull_conflicts(part_number, existing_local, doc) + if conflicts: + detail = "\n".join(f" - {c}" for c in conflicts) + reply = QtGui.QMessageBox.warning( + None, + "Pull - Conflicts Detected", + f"Potential conflicts found:\n{detail}\n\n" + "Download anyway and overwrite local file?", + QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, + ) + if reply != QtGui.QMessageBox.Yes: + return + + # Show revision selection dialog + dlg = SiloPullDialog(part_number, revisions) + selected = dlg.exec_() + if selected is None: + return + + rev_num = selected["revision_number"] + + # Determine destination path + try: + item = _client.get_item(part_number) + except Exception: + item = {} + dest_path = get_cad_file_path(part_number, item.get("description", "")) + dest_path.parent.mkdir(parents=True, exist_ok=True) + + # Download with progress + progress = QtGui.QProgressDialog( + f"Downloading {part_number} rev {rev_num}...", "Cancel", 0, 100 + ) + progress.setWindowModality(2) # Qt.WindowModal + progress.setMinimumDuration(0) + progress.setValue(0) + + def on_progress(downloaded, total): + if progress.wasCanceled(): + return + if total > 0: + pct = int(downloaded * 100 / total) + progress.setValue(min(pct, 99)) + else: + # Indeterminate - pulse between 0-90 + progress.setValue(min(int(downloaded / 1024) % 90, 89)) + QtGui.QApplication.processEvents() + + try: + ok = _client._download_file( + part_number, rev_num, str(dest_path), progress_callback=on_progress + ) + except Exception as e: + progress.close() + QtGui.QMessageBox.warning(None, "Pull", f"Download failed: {e}") + return + + progress.setValue(100) + progress.close() + + if not ok: + QtGui.QMessageBox.warning(None, "Pull", "Download failed.") + return + + FreeCAD.Console.PrintMessage(f"Pulled revision {rev_num} of {part_number}\n") + + # Close existing document if open, then reopen + if doc and doc.FileName == str(dest_path): + FreeCAD.closeDocument(doc.Name) + FreeCAD.openDocument(str(dest_path)) + + # Update SiloRevision property on the tracked object + new_doc = FreeCAD.ActiveDocument + if new_doc: + new_obj = get_tracked_object(new_doc) + if new_obj and hasattr(new_obj, "SiloRevision"): + new_obj.SiloRevision = rev_num + new_doc.save() def IsActive(self): return True @@ -2914,6 +3138,139 @@ class Silo_ToggleMode: return True +# --------------------------------------------------------------------------- +# SSE live-update listener +# --------------------------------------------------------------------------- + + +class SiloEventListener(QtCore.QThread): + """Background thread that listens to Server-Sent Events from the Silo API. + + Emits Qt signals when items are updated or new revisions are created. + Degrades gracefully if the server does not support the ``/api/events`` + endpoint. + """ + + item_updated = QtCore.Signal(str) # part_number + revision_created = QtCore.Signal(str, int) # part_number, revision + connection_status = QtCore.Signal( + str + ) # "connected" / "disconnected" / "unsupported" + + _MAX_FAST_RETRIES = 3 + _FAST_RETRY_SECS = 5 + _SLOW_RETRY_SECS = 30 + + def __init__(self, parent=None): + super().__init__(parent) + self._stop_flag = False + self._response = None + + # -- public API --------------------------------------------------------- + + def stop(self): + self._stop_flag = True + # Close the socket so readline() unblocks immediately + try: + if self._response is not None: + self._response.close() + except Exception: + pass + self.wait(5000) + + # -- thread entry ------------------------------------------------------- + + def run(self): + retries = 0 + while not self._stop_flag: + try: + self._listen() + # _listen returns normally only on clean EOF / stop + if self._stop_flag: + return + retries += 1 + except _SSEUnsupported: + self.connection_status.emit("unsupported") + return + except Exception: + retries += 1 + + self.connection_status.emit("disconnected") + + if retries <= self._MAX_FAST_RETRIES: + delay = self._FAST_RETRY_SECS + else: + delay = self._SLOW_RETRY_SECS + + # Interruptible sleep + for _ in range(delay): + if self._stop_flag: + return + self.msleep(1000) + + # -- SSE stream reader -------------------------------------------------- + + def _listen(self): + url = f"{_get_api_url().rstrip('/')}/api/events" + headers = {"Accept": "text/event-stream"} + headers.update(_get_auth_headers()) + req = urllib.request.Request(url, headers=headers, method="GET") + + try: + 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() + raise + except urllib.error.URLError: + raise + + self.connection_status.emit("connected") + + event_type = "" + data_buf = "" + + for raw_line in self._response: + if self._stop_flag: + return + + line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + + if line == "": + # Blank line = dispatch event + if data_buf: + self._dispatch(event_type or "message", data_buf.strip()) + event_type = "" + data_buf = "" + elif line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_buf += line[5:].strip() + "\n" + # Ignore comments (lines starting with ':') and other fields + + def _dispatch(self, event_type, data): + try: + payload = json.loads(data) + except (json.JSONDecodeError, ValueError): + return + + pn = payload.get("part_number", "") + if not pn: + return + + if event_type in ("item_updated", "message"): + self.item_updated.emit(pn) + elif event_type == "revision_created": + rev = payload.get("revision", 0) + self.revision_created.emit(pn, int(rev)) + + +class _SSEUnsupported(Exception): + """Raised when the server does not support the SSE endpoint.""" + + # --------------------------------------------------------------------------- # Auth dock widget # --------------------------------------------------------------------------- @@ -2926,6 +3283,7 @@ class SiloAuthDockWidget: from PySide import QtCore, QtGui self.widget = QtGui.QWidget() + self._event_listener = None self._build_ui() self._refresh_status() @@ -2993,6 +3351,20 @@ class SiloAuthDockWidget: layout.addSpacing(4) + # Live updates row + sse_row = QtGui.QHBoxLayout() + sse_row.setSpacing(6) + sse_lbl = QtGui.QLabel("Live:") + sse_lbl.setStyleSheet("color: #888;") + self._sse_label = QtGui.QLabel("") + self._sse_label.setStyleSheet("font-size: 11px;") + sse_row.addWidget(sse_lbl) + sse_row.addWidget(self._sse_label) + sse_row.addStretch() + layout.addLayout(sse_row) + + layout.addSpacing(4) + # Buttons btn_row = QtGui.QHBoxLayout() btn_row.setSpacing(6) @@ -3080,6 +3452,83 @@ class SiloAuthDockWidget: self._login_btn.setText("Login") self._login_btn.clicked.connect(self._on_login_clicked) + # Manage SSE listener based on auth state + self._sync_event_listener(authed) + + # -- SSE listener management -------------------------------------------- + + def _sync_event_listener(self, authed): + """Start or stop the SSE listener depending on authentication state.""" + if authed: + if self._event_listener is None or not self._event_listener.isRunning(): + self._event_listener = SiloEventListener() + self._event_listener.item_updated.connect(self._on_remote_change) + self._event_listener.revision_created.connect(self._on_remote_revision) + self._event_listener.connection_status.connect(self._on_sse_status) + self._event_listener.start() + else: + if self._event_listener is not None and self._event_listener.isRunning(): + self._event_listener.stop() + self._sse_label.setText("") + + def _on_sse_status(self, status): + if status == "connected": + self._sse_label.setText("Listening") + self._sse_label.setStyleSheet("font-size: 11px; color: #4CAF50;") + elif status == "disconnected": + self._sse_label.setText("Reconnecting...") + self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;") + elif status == "unsupported": + self._sse_label.setText("Not available") + self._sse_label.setStyleSheet("font-size: 11px; color: #888;") + + def _on_remote_change(self, part_number): + FreeCAD.Console.PrintMessage(f"Silo: Part {part_number} updated on server\n") + mw = FreeCADGui.getMainWindow() + if mw is not None: + mw.statusBar().showMessage(f"Silo: {part_number} updated on server", 5000) + self._refresh_activity_panel() + + def _on_remote_revision(self, part_number, revision): + 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 + ) + self._refresh_activity_panel() + + def _refresh_activity_panel(self): + """Refresh the Database Activity panel if it exists.""" + from PySide import QtWidgets + + mw = FreeCADGui.getMainWindow() + if mw is None: + return + panel = mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseActivity") + if panel is None: + return + activity_list = panel.findChild(QtWidgets.QListWidget) + if activity_list is None: + return + activity_list.clear() + try: + items = _client.list_items() + if isinstance(items, list): + for item in items[:20]: + pn = item.get("part_number", "") + desc = item.get("description", "") + updated = item.get("updated_at", "") + if updated: + updated = updated[:10] + activity_list.addItem(f"{pn} - {desc} - {updated}") + if activity_list.count() == 0: + activity_list.addItem("(No items in database)") + except Exception: + activity_list.addItem("(Unable to refresh activity)") + # -- Actions ------------------------------------------------------------ def _on_login_clicked(self):