From 85bfb178541b04ff4c2854cd2fd3ef1723345ba5 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Tue, 10 Feb 2026 10:30:12 -0600 Subject: [PATCH] feat: native Qt start panel with Silo API + kindred:// URL scheme Replace QWebEngineView-based start page with a rich native Qt panel that fetches items directly from the Silo REST API. QWebEngineView is not available on conda-forge for Qt6. Start panel features: - Database Items list with search (from SiloClient.list_items) - Recent Files list from FreeCAD preferences - Real-time Activity Feed via SSE (SiloEventListener) - Context menu: Open in Create, Open in Browser, Copy Part Number - Open in Browser button (QDesktopServices) - Catppuccin Mocha dark theme styling URL scheme support: - handle_kindred_url() function for kindred://item/{part_number} URLs - Startup hook in InitGui.py for cold-start URL arguments Closes #167 --- freecad/InitGui.py | 19 ++ freecad/silo_commands.py | 223 +++++-------- freecad/silo_start.py | 703 ++++++++++++++++++++++++++------------- 3 files changed, 561 insertions(+), 384 deletions(-) diff --git a/freecad/InitGui.py b/freecad/InitGui.py index 683c38f..e410398 100644 --- a/freecad/InitGui.py +++ b/freecad/InitGui.py @@ -76,3 +76,22 @@ try: silo_start.register() except Exception as e: FreeCAD.Console.PrintWarning(f"Silo Start page override failed: {e}\n") + + +# Handle kindred:// URLs passed as command-line arguments on cold start. +# Delayed to run after the GUI is fully initialised and the Silo addon has +# loaded its client/sync objects. +def _handle_startup_urls(): + """Process any kindred:// URLs passed as command-line arguments.""" + import sys + + from silo_commands import handle_kindred_url + + for arg in sys.argv[1:]: + if arg.startswith("kindred://"): + handle_kindred_url(arg) + + +from PySide import QtCore + +QtCore.QTimer.singleShot(500, _handle_startup_urls) diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index f4d6bac..1bd06dc 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -26,9 +26,7 @@ from silo_client import ( _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")) # --------------------------------------------------------------------------- @@ -66,9 +64,7 @@ class FreeCADSiloSettings(SiloSettings): param = FreeCAD.ParamGet(_PREF_GROUP) return param.GetString("SslCertPath", "") - def save_auth( - self, username: str, role: str = "", source: str = "", token: str = "" - ): + def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""): param = FreeCAD.ParamGet(_PREF_GROUP) param.SetString("AuthUsername", username) param.SetString("AuthRole", role) @@ -126,9 +122,7 @@ def _get_ssl_verify() -> bool: def _get_ssl_context(): from silo_client._ssl import build_ssl_context - return build_ssl_context( - _fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path() - ) + return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()) def _get_auth_headers() -> Dict[str, str]: @@ -185,9 +179,7 @@ def _fetch_server_mode() -> str: # Icon helper # --------------------------------------------------------------------------- -_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): @@ -557,6 +549,35 @@ class SiloSync: _sync = SiloSync() +# --------------------------------------------------------------------------- +# kindred:// URL handler +# --------------------------------------------------------------------------- + + +def handle_kindred_url(url: str): + """Handle a ``kindred://`` URL by opening the referenced item. + + URL format:: + + kindred://item/{part_number} + kindred://item/{part_number}/revision/{rev_number} + + Called from C++ ``MainWindow::processMessages()`` when a ``kindred://`` + URL arrives via IPC, or from ``InitGui.py`` for cold-start URL arguments. + """ + from urllib.parse import urlparse + + parsed = urlparse(url) + if parsed.scheme != "kindred": + return + # urlparse treats "kindred://item/PN-001" as netloc="item", path="/PN-001" + parts = [parsed.netloc] + [p for p in parsed.path.split("/") if p] + if len(parts) >= 2 and parts[0] == "item": + part_number = parts[1] + FreeCAD.Console.PrintMessage(f"Silo: Opening item {part_number} from kindred:// URL\n") + _sync.open_item(part_number) + + # ============================================================================ # COMMANDS # ============================================================================ @@ -642,11 +663,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: @@ -670,12 +687,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"])) @@ -741,13 +754,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 ) @@ -755,15 +764,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 @@ -808,9 +813,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") @@ -917,9 +920,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") @@ -952,9 +953,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 @@ -971,9 +970,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") @@ -1020,9 +1017,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: @@ -1052,9 +1047,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) @@ -1168,18 +1161,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, @@ -1310,9 +1299,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: @@ -1325,9 +1312,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" @@ -1345,9 +1330,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 @@ -1396,7 +1379,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', '-')}

" @@ -1462,9 +1447,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() @@ -1575,9 +1558,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 @@ -1592,12 +1573,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) @@ -1623,9 +1600,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() @@ -1723,9 +1698,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}'" ) @@ -1789,9 +1762,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) @@ -2068,9 +2039,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) @@ -2099,16 +2068,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( @@ -2130,16 +2095,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() @@ -2192,9 +2153,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 @@ -2273,9 +2232,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 @@ -2299,9 +2256,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() @@ -2327,9 +2282,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) @@ -2368,9 +2321,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, int, str - ) # (status, retry_count, error_message) + connection_status = QtCore.Signal(str, int, str) # (status, retry_count, error_message) server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded" _MAX_RETRIES = 10 @@ -2437,9 +2388,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() @@ -2720,9 +2669,7 @@ class SiloAuthDockWidget: self._sse_label.setToolTip("") FreeCAD.Console.PrintMessage("Silo: SSE connected\n") elif status == "disconnected": - self._sse_label.setText( - f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..." - ) + self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...") self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;") self._sse_label.setToolTip(error or "Connection lost") FreeCAD.Console.PrintWarning( @@ -2732,9 +2679,7 @@ class SiloAuthDockWidget: self._sse_label.setText("Disconnected") self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;") self._sse_label.setToolTip(error or "Max retries reached") - FreeCAD.Console.PrintError( - f"Silo: SSE gave up after {retry} retries: {error}\n" - ) + FreeCAD.Console.PrintError(f"Silo: SSE gave up after {retry} retries: {error}\n") elif status == "unsupported": self._sse_label.setText("Not available") self._sse_label.setStyleSheet("font-size: 11px; color: #888;") @@ -2776,14 +2721,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): @@ -2849,9 +2790,7 @@ class SiloAuthDockWidget: rev_part = f" \u2013 Rev {rev_num}" if rev_num else "" date_part = f" \u2013 {updated}" if updated else "" local_badge = " \u25cf local" if pn in local_pns else "" - line1 = ( - f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}" - ) + line1 = f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}" if comment: line1 += f'\n "{comment}"' @@ -3307,9 +3246,7 @@ class Silo_StartPanel: dock = QtGui.QDockWidget("Silo", mw) dock.setObjectName("SiloStartPanel") dock.setWidget(content.widget) - dock.setAllowedAreas( - QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea - ) + dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea) mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock) def IsActive(self): @@ -3343,9 +3280,7 @@ class _DiagWorker(QtCore.QThread): self.result.emit("DNS", False, "no hostname in URL") return try: - addrs = socket.getaddrinfo( - hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM - ) + addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) first_ip = addrs[0][4][0] if addrs else "?" self.result.emit("DNS", True, f"{hostname} -> {first_ip}") except socket.gaierror as e: diff --git a/freecad/silo_start.py b/freecad/silo_start.py index 00a4cee..a224ec6 100644 --- a/freecad/silo_start.py +++ b/freecad/silo_start.py @@ -1,18 +1,14 @@ -"""Silo Start Page — dual-mode start view for Kindred Create. +"""Silo Start Page — native Qt start view for Kindred Create. -Replaces the default Start page with either: -- A QWebEngineView showing the Silo web app (when Silo is reachable) -- A native Qt offline fallback with recent files and connectivity status +Replaces the default Start page with a rich native panel that fetches data +from the Silo REST API, shows real-time activity via SSE, and provides quick +access to database items and recent local files. The command override is activated by calling ``register()`` at module level from InitGui.py, which overwrites the C++ ``Start_Start`` command. """ import os -import ssl -import urllib.error -import urllib.parse -import urllib.request from datetime import datetime from pathlib import Path @@ -20,17 +16,6 @@ import FreeCAD import FreeCADGui from PySide import QtCore, QtGui, QtWidgets -# Try to import QtWebEngineWidgets — not all builds ship it -_HAS_WEBENGINE = False -try: - from PySide import QtWebEngineWidgets - - _HAS_WEBENGINE = True -except ImportError: - FreeCAD.Console.PrintLog( - "Silo Start: QtWebEngineWidgets not available, using offline mode only\n" - ) - # --------------------------------------------------------------------------- # Catppuccin Mocha palette # --------------------------------------------------------------------------- @@ -53,8 +38,6 @@ _MOCHA = { } _PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo" -_POLL_INTERVAL_MS = 5000 -_CONNECT_TIMEOUT_S = 2 # --------------------------------------------------------------------------- @@ -69,34 +52,11 @@ def _get_silo_base_url() -> str: if not url: url = os.environ.get("SILO_API_URL", "http://localhost:8080/api") url = url.rstrip("/") - # Strip trailing /api to get the web root if url.endswith("/api"): url = url[:-4] return url -def _get_ssl_context() -> ssl.SSLContext: - """Build an SSL context respecting the Silo SSL preference.""" - param = FreeCAD.ParamGet(_PREF_GROUP) - if param.GetBool("SslVerify", True): - return ssl.create_default_context() - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - return ctx - - -def _check_connectivity(url: str) -> bool: - """HEAD-request the Silo base URL, return True if reachable.""" - try: - req = urllib.request.Request(url, method="HEAD") - req.add_header("User-Agent", "kindred-create/1.0") - urllib.request.urlopen(req, timeout=_CONNECT_TIMEOUT_S, context=_get_ssl_context()) - return True - except Exception: - return False - - def _get_recent_files() -> list: """Read recent files from FreeCAD preferences.""" group = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/RecentFiles") @@ -111,63 +71,285 @@ def _get_recent_files() -> list: return files +def _relative_time(dt: datetime) -> str: + """Format a datetime as a human-friendly relative string.""" + now = datetime.now() + diff = now - dt + seconds = int(diff.total_seconds()) + if seconds < 60: + return "just now" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + days = hours // 24 + if days < 30: + return f"{days}d ago" + return dt.strftime("%Y-%m-%d") + + # --------------------------------------------------------------------------- -# Offline fallback widget +# Stylesheet +# --------------------------------------------------------------------------- + +_STYLESHEET = f""" +SiloStartView {{ + background-color: {_MOCHA["base"]}; +}} + +/* --- Status banner --- */ +#SiloStatusBanner {{ + background-color: {_MOCHA["surface0"]}; + border-radius: 8px; +}} +#SiloStatusBanner QLabel {{ + color: {_MOCHA["text"]}; + font-size: 13px; +}} +#SiloStatusBanner QPushButton {{ + background-color: {_MOCHA["blue"]}; + color: {_MOCHA["crust"]}; + border: none; + border-radius: 4px; + padding: 6px 12px; + font-weight: bold; + font-size: 12px; +}} +#SiloStatusBanner QPushButton:hover {{ + background-color: {_MOCHA["lavender"]}; +}} + +/* --- Section headers --- */ +.SiloSectionHeader {{ + color: {_MOCHA["text"]}; + font-size: 14px; + font-weight: bold; +}} + +/* --- Search field --- */ +#SiloSearchField {{ + background-color: {_MOCHA["surface0"]}; + border: 1px solid {_MOCHA["surface1"]}; + border-radius: 4px; + padding: 6px 10px; + color: {_MOCHA["text"]}; + font-size: 13px; +}} +#SiloSearchField:focus {{ + border-color: {_MOCHA["blue"]}; +}} + +/* --- List widgets --- */ +.SiloList {{ + background-color: {_MOCHA["mantle"]}; + border: 1px solid {_MOCHA["surface0"]}; + border-radius: 6px; + padding: 4px; +}} +.SiloList::item {{ + padding: 8px 10px; + border-bottom: 1px solid {_MOCHA["surface0"]}; + color: {_MOCHA["text"]}; +}} +.SiloList::item:last {{ + border-bottom: none; +}} +.SiloList::item:hover {{ + background-color: {_MOCHA["surface0"]}; +}} +.SiloList::item:selected {{ + background-color: {_MOCHA["surface1"]}; +}} + +/* --- Activity feed --- */ +#SiloActivityFeed {{ + background-color: {_MOCHA["mantle"]}; + border: 1px solid {_MOCHA["surface0"]}; + border-radius: 6px; + padding: 4px; +}} +#SiloActivityFeed::item {{ + padding: 6px 10px; + border-bottom: 1px solid {_MOCHA["surface0"]}; + color: {_MOCHA["subtext0"]}; + font-size: 12px; +}} +#SiloActivityFeed::item:last {{ + border-bottom: none; +}} + +/* --- Footer checkbox --- */ +QCheckBox {{ + color: {_MOCHA["subtext0"]}; + font-size: 12px; +}} +QCheckBox::indicator {{ + width: 14px; + height: 14px; +}} +""" + + +# --------------------------------------------------------------------------- +# Main start view # --------------------------------------------------------------------------- -class _OfflineWidget(QtWidgets.QWidget): - """Native Qt fallback showing Silo status and recent local files.""" - - retry_clicked = QtCore.Signal() +class SiloStartView(QtWidgets.QWidget): + """Native Qt start page with Silo database items, recent files, and + real-time activity feed.""" def __init__(self, parent=None): super().__init__(parent) + self.setObjectName("SiloStartView") + + self._silo_url = _get_silo_base_url() + self._connected = False + self._event_listener = None + self._activity_events = [] # list of (datetime, text) tuples + self._silo_imported = False + self._silo_cmds = None # lazy ref to silo_commands module + self._build_ui() - self.update_status(False, _get_silo_base_url()) + self.setStyleSheet(_STYLESHEET) + + # Debounce timer for search + self._search_timer = QtCore.QTimer(self) + self._search_timer.setSingleShot(True) + self._search_timer.setInterval(300) + self._search_timer.timeout.connect(self._refresh_items) + + # Periodic refresh + self._poll_timer = QtCore.QTimer(self) + self._poll_timer.setInterval(30000) + self._poll_timer.timeout.connect(self._periodic_refresh) + self._poll_timer.start() + + # Initial load after event loop starts + QtCore.QTimer.singleShot(100, self._initial_load) + + # -- lazy import -------------------------------------------------------- + + def _silo(self): + """Lazy-import silo_commands to avoid circular import at module load.""" + if not self._silo_imported: + try: + import silo_commands + + self._silo_cmds = silo_commands + except Exception as e: + FreeCAD.Console.PrintWarning(f"Silo Start: cannot import silo_commands: {e}\n") + self._silo_cmds = None + self._silo_imported = True + return self._silo_cmds + + # -- UI construction ---------------------------------------------------- def _build_ui(self): - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(40, 30, 40, 20) - layout.setSpacing(0) + root = QtWidgets.QVBoxLayout(self) + root.setContentsMargins(32, 24, 32, 16) + root.setSpacing(0) # --- Status banner --- banner = QtWidgets.QFrame() banner.setObjectName("SiloStatusBanner") banner_layout = QtWidgets.QHBoxLayout(banner) - banner_layout.setContentsMargins(16, 12, 16, 12) + banner_layout.setContentsMargins(16, 10, 16, 10) self._status_icon = QtWidgets.QLabel() self._status_icon.setFixedSize(12, 12) banner_layout.addWidget(self._status_icon) - self._status_label = QtWidgets.QLabel() + self._status_label = QtWidgets.QLabel("Checking Silo connection...") self._status_label.setWordWrap(True) banner_layout.addWidget(self._status_label, 1) + self._browser_btn = QtWidgets.QPushButton("Open in Browser") + self._browser_btn.setFixedWidth(130) + self._browser_btn.setCursor(QtCore.Qt.PointingHandCursor) + self._browser_btn.clicked.connect(self._open_in_browser) + banner_layout.addWidget(self._browser_btn) + self._retry_btn = QtWidgets.QPushButton("Retry") - self._retry_btn.setFixedWidth(80) + self._retry_btn.setFixedWidth(70) self._retry_btn.setCursor(QtCore.Qt.PointingHandCursor) - self._retry_btn.clicked.connect(self.retry_clicked.emit) + self._retry_btn.clicked.connect(self._initial_load) + self._retry_btn.hide() banner_layout.addWidget(self._retry_btn) - layout.addWidget(banner) - layout.addSpacing(24) + root.addWidget(banner) + root.addSpacing(20) - # --- Recent files header --- - header = QtWidgets.QLabel("Recent Files") - header.setObjectName("SiloRecentHeader") - layout.addWidget(header) - layout.addSpacing(12) + # --- Main content: items (left) + recent files (right) --- + content_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) + content_splitter.setHandleWidth(12) + content_splitter.setStyleSheet( + f"QSplitter::handle {{ background-color: {_MOCHA['base']}; }}" + ) + + # Left: Database Items + left = QtWidgets.QWidget() + left_layout = QtWidgets.QVBoxLayout(left) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.setSpacing(8) + + items_header = QtWidgets.QLabel("Database Items") + items_header.setProperty("class", "SiloSectionHeader") + left_layout.addWidget(items_header) + + self._search_field = QtWidgets.QLineEdit() + self._search_field.setObjectName("SiloSearchField") + self._search_field.setPlaceholderText("Search items...") + self._search_field.textChanged.connect(self._on_search_changed) + left_layout.addWidget(self._search_field) + + self._items_list = QtWidgets.QListWidget() + self._items_list.setProperty("class", "SiloList") + self._items_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self._items_list.itemDoubleClicked.connect(self._on_item_double_clicked) + self._items_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self._items_list.customContextMenuRequested.connect(self._on_item_context_menu) + left_layout.addWidget(self._items_list, 1) + + content_splitter.addWidget(left) + + # Right: Recent Files + right = QtWidgets.QWidget() + right_layout = QtWidgets.QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(8) + + recent_header = QtWidgets.QLabel("Recent Files") + recent_header.setProperty("class", "SiloSectionHeader") + right_layout.addWidget(recent_header) - # --- Recent files list --- self._file_list = QtWidgets.QListWidget() - self._file_list.setObjectName("SiloRecentList") + self._file_list.setProperty("class", "SiloList") self._file_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self._file_list.itemDoubleClicked.connect(self._on_file_clicked) - layout.addWidget(self._file_list, 1) + right_layout.addWidget(self._file_list, 1) - layout.addSpacing(16) + content_splitter.addWidget(right) + content_splitter.setStretchFactor(0, 3) + content_splitter.setStretchFactor(1, 2) + + root.addWidget(content_splitter, 1) + root.addSpacing(12) + + # --- Activity Feed (bottom) --- + activity_header = QtWidgets.QLabel("Activity") + activity_header.setProperty("class", "SiloSectionHeader") + root.addWidget(activity_header) + root.addSpacing(6) + + self._activity_list = QtWidgets.QListWidget() + self._activity_list.setObjectName("SiloActivityFeed") + self._activity_list.setMaximumHeight(140) + self._activity_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + root.addWidget(self._activity_list) + root.addSpacing(12) # --- Footer --- footer = QtWidgets.QHBoxLayout() @@ -178,103 +360,234 @@ class _OfflineWidget(QtWidgets.QWidget): self._startup_cb.setChecked(not show) self._startup_cb.toggled.connect(self._on_startup_toggled) footer.addWidget(self._startup_cb) - layout.addLayout(footer) + root.addLayout(footer) - self._apply_style() + # -- data loading ------------------------------------------------------- - def _apply_style(self): - self.setStyleSheet(f""" - _OfflineWidget {{ - background-color: {_MOCHA["base"]}; - }} - #SiloStatusBanner {{ - background-color: {_MOCHA["surface0"]}; - border-radius: 8px; - }} - #SiloStatusBanner QLabel {{ - color: {_MOCHA["text"]}; - font-size: 13px; - }} - #SiloStatusBanner QPushButton {{ - background-color: {_MOCHA["blue"]}; - color: {_MOCHA["crust"]}; - border: none; - border-radius: 4px; - padding: 6px 12px; - font-weight: bold; - font-size: 12px; - }} - #SiloStatusBanner QPushButton:hover {{ - background-color: {_MOCHA["lavender"]}; - }} - #SiloRecentHeader {{ - color: {_MOCHA["text"]}; - font-size: 20px; - font-weight: bold; - }} - #SiloRecentList {{ - background-color: {_MOCHA["mantle"]}; - border: 1px solid {_MOCHA["surface0"]}; - border-radius: 6px; - padding: 4px; - }} - #SiloRecentList::item {{ - padding: 10px 12px; - border-bottom: 1px solid {_MOCHA["surface0"]}; - color: {_MOCHA["text"]}; - }} - #SiloRecentList::item:last {{ - border-bottom: none; - }} - #SiloRecentList::item:hover {{ - background-color: {_MOCHA["surface0"]}; - }} - #SiloRecentList::item:selected {{ - background-color: {_MOCHA["surface1"]}; - }} - QCheckBox {{ - color: {_MOCHA["subtext0"]}; - font-size: 12px; - }} - QCheckBox::indicator {{ - width: 14px; - height: 14px; - }} - """) + def _initial_load(self): + """First-time data load and SSE connection.""" + self._refresh_status() + self._refresh_items() + self._refresh_recent_files() + self._start_sse() - def update_status(self, connected: bool, url: str, error: str = ""): + def _periodic_refresh(self): + """Periodic refresh of items and connection status.""" + self._refresh_status() + self._refresh_items() + self._refresh_recent_files() + + def _refresh_status(self): + """Update the connection status banner.""" + sc = self._silo() + if sc is None: + self._set_status(False, "Silo addon not available") + return + + try: + reachable, _ = sc._client.check_connection() + except Exception: + reachable = False + + self._silo_url = _get_silo_base_url() + if reachable: + self._set_status(True, f"Connected to {self._silo_url}") + else: + self._set_status(False, f"Cannot reach {self._silo_url}") + + def _set_status(self, connected: bool, message: str): + self._connected = connected if connected: self._status_icon.setStyleSheet( f"background-color: {_MOCHA['green']}; border-radius: 6px;" ) - self._status_label.setText(f"Silo connected — {url}") self._retry_btn.hide() + self._browser_btn.show() else: self._status_icon.setStyleSheet( f"background-color: {_MOCHA['red']}; border-radius: 6px;" ) - msg = f"Silo not reachable — {url}" - if error: - msg += f" ({error})" - if not _HAS_WEBENGINE: - msg += " [WebEngine not available]" - self._status_label.setText(msg) self._retry_btn.show() + self._browser_btn.hide() + self._status_label.setText(message) - def refresh_recent_files(self): + def _refresh_items(self): + """Fetch items from Silo API and populate the items list.""" + sc = self._silo() + if sc is None: + return + + self._items_list.clear() + search = self._search_field.text().strip() + + try: + if search: + items = sc._client.list_items(search=search) + else: + items = sc._client.list_items() + except Exception: + item = QtWidgets.QListWidgetItem("(Unable to fetch items)") + item.setFlags(QtCore.Qt.NoItemFlags) + self._items_list.addItem(item) + return + + if not isinstance(items, list) or not items: + item = QtWidgets.QListWidgetItem("(No items found)") + item.setFlags(QtCore.Qt.NoItemFlags) + self._items_list.addItem(item) + return + + # Collect local part numbers for badge + local_pns = set() + try: + for lf in sc.search_local_files(): + local_pns.add(lf.get("part_number", "")) + except Exception: + pass + + for entry in items[:30]: + pn = entry.get("part_number", "") + desc = entry.get("description", "") + rev = entry.get("current_revision", "") + + desc_display = desc + if len(desc_display) > 50: + desc_display = desc_display[:47] + "..." + + local_badge = " [local]" if pn in local_pns else "" + rev_part = f" Rev {rev}" if rev else "" + label = f"{pn} \u2014 {desc_display}{rev_part}{local_badge}" + + list_item = QtWidgets.QListWidgetItem(label) + list_item.setData(QtCore.Qt.UserRole, pn) + if desc and len(desc) > 50: + list_item.setToolTip(desc) + if pn in local_pns: + list_item.setForeground(QtGui.QColor(_MOCHA["green"])) + self._items_list.addItem(list_item) + + def _refresh_recent_files(self): + """Populate the recent files list.""" self._file_list.clear() files = _get_recent_files() if not files: - item = QtWidgets.QListWidgetItem("No recent files") + item = QtWidgets.QListWidgetItem("(No recent files)") item.setFlags(QtCore.Qt.NoItemFlags) self._file_list.addItem(item) return for f in files: - label = f"{f['name']}\n{f['path']}\nModified: {f['modified']:%Y-%m-%d %H:%M}" + label = f"{f['name']}\n{_relative_time(f['modified'])}" item = QtWidgets.QListWidgetItem(label) item.setData(QtCore.Qt.UserRole, f["path"]) + item.setToolTip(f["path"]) self._file_list.addItem(item) + # -- SSE ---------------------------------------------------------------- + + def _start_sse(self): + """Connect to SSE for live activity updates.""" + sc = self._silo() + if sc is None: + return + + if self._event_listener is not None: + return # already running + + try: + self._event_listener = sc.SiloEventListener() + self._event_listener.item_updated.connect(self._on_sse_item_updated) + self._event_listener.revision_created.connect(self._on_sse_revision_created) + self._event_listener.connection_status.connect(self._on_sse_status) + self._event_listener.start() + except Exception as e: + FreeCAD.Console.PrintLog(f"Silo Start: SSE listener failed to start: {e}\n") + + def _stop_sse(self): + """Stop SSE listener when the view is destroyed.""" + if self._event_listener is not None: + self._event_listener.stop() + self._event_listener = None + + def _on_sse_item_updated(self, part_number: str): + self._add_activity_event(f"{part_number} updated") + self._refresh_items() + + def _on_sse_revision_created(self, part_number: str, revision: int): + self._add_activity_event(f"{part_number} Rev {revision} created") + self._refresh_items() + + def _on_sse_status(self, status: str, retry: int, error: str): + if status == "connected": + FreeCAD.Console.PrintLog("Silo Start: SSE connected\n") + elif status == "disconnected": + FreeCAD.Console.PrintLog(f"Silo Start: SSE disconnected (retry {retry}): {error}\n") + + def _add_activity_event(self, text: str): + """Add an event to the activity feed.""" + now = datetime.now() + self._activity_events.insert(0, (now, text)) + self._activity_events = self._activity_events[:20] + self._rebuild_activity_feed() + + def _rebuild_activity_feed(self): + """Rebuild the activity list widget from stored events.""" + self._activity_list.clear() + if not self._activity_events: + item = QtWidgets.QListWidgetItem("(No recent activity)") + item.setFlags(QtCore.Qt.NoItemFlags) + self._activity_list.addItem(item) + return + for ts, text in self._activity_events: + label = f"{text} \u00b7 {_relative_time(ts)}" + self._activity_list.addItem(label) + + # -- interaction -------------------------------------------------------- + + def _on_search_changed(self, _text: str): + """Debounce search input.""" + self._search_timer.start() + + def _on_item_double_clicked(self, item: QtWidgets.QListWidgetItem): + pn = item.data(QtCore.Qt.UserRole) + if not pn: + return + sc = self._silo() + if sc is None: + return + local_path = sc.find_file_by_part_number(pn) + if local_path and local_path.exists(): + FreeCAD.openDocument(str(local_path)) + else: + sc._sync.open_item(pn) + + def _on_item_context_menu(self, pos): + item = self._items_list.itemAt(pos) + if item is None: + return + pn = item.data(QtCore.Qt.UserRole) + if not pn: + return + + menu = QtWidgets.QMenu() + open_action = menu.addAction("Open in Create") + browser_action = menu.addAction("Open in Browser") + copy_action = menu.addAction("Copy Part Number") + + action = menu.exec_(self._items_list.mapToGlobal(pos)) + sc = self._silo() + if action == open_action: + if sc: + local_path = sc.find_file_by_part_number(pn) + if local_path and local_path.exists(): + FreeCAD.openDocument(str(local_path)) + else: + sc._sync.open_item(pn) + elif action == browser_action: + url = f"{_get_silo_base_url()}/items/{pn}" + QtGui.QDesktopServices.openUrl(QtCore.QUrl(url)) + elif action == copy_action: + QtWidgets.QApplication.clipboard().setText(pn) + def _on_file_clicked(self, item: QtWidgets.QListWidgetItem): path = item.data(QtCore.Qt.UserRole) if path: @@ -283,112 +596,22 @@ class _OfflineWidget(QtWidgets.QWidget): except Exception as e: FreeCAD.Console.PrintError(f"Silo Start: failed to open {path}: {e}\n") + def _open_in_browser(self): + """Open Silo web UI in the system browser.""" + url = _get_silo_base_url() + QtGui.QDesktopServices.openUrl(QtCore.QUrl(url)) + @staticmethod def _on_startup_toggled(checked: bool): prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Start") prefs.SetBool("ShowOnStartup", not checked) + # -- cleanup ------------------------------------------------------------ -# --------------------------------------------------------------------------- -# Web engine page (navigation filter) -# --------------------------------------------------------------------------- - -if _HAS_WEBENGINE: - - class _SiloPage(QtWebEngineWidgets.QWebEnginePage): - """Custom page that keeps navigation within the Silo origin.""" - - def __init__(self, silo_origin: str, parent=None): - super().__init__(parent) - self._silo_origin = silo_origin - - def acceptNavigationRequest(self, url, nav_type, is_main_frame): - if nav_type == QtWebEngineWidgets.QWebEnginePage.NavigationTypeLinkClicked: - target = url.toString() - if not target.startswith(self._silo_origin): - QtGui.QDesktopServices.openUrl(url) - return False - return super().acceptNavigationRequest(url, nav_type, is_main_frame) - - -# --------------------------------------------------------------------------- -# Main start view -# --------------------------------------------------------------------------- - - -class SiloStartView(QtWidgets.QWidget): - """Dual-mode start page: Silo webview (online) / offline fallback.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setObjectName("SiloStartView") - - self._silo_url = _get_silo_base_url() - self._connected = False - - # Stack: page 0 = webview (or placeholder), page 1 = offline - self._stack = QtWidgets.QStackedWidget(self) - root_layout = QtWidgets.QVBoxLayout(self) - root_layout.setContentsMargins(0, 0, 0, 0) - root_layout.addWidget(self._stack) - - # Page 0: web view - self._web_view = None - if _HAS_WEBENGINE: - self._web_view = QtWebEngineWidgets.QWebEngineView() - page = _SiloPage(self._silo_url, self._web_view) - self._web_view.setPage(page) - self._stack.addWidget(self._web_view) # index 0 - else: - # placeholder so indices stay consistent - placeholder = QtWidgets.QWidget() - self._stack.addWidget(placeholder) # index 0 - - # Page 1: offline fallback - self._offline = _OfflineWidget() - self._offline.retry_clicked.connect(self._check_now) - self._offline.refresh_recent_files() - self._stack.addWidget(self._offline) # index 1 - - # Start on offline page, then check connectivity - self._stack.setCurrentIndex(1) - - # Connectivity polling timer - self._poll_timer = QtCore.QTimer(self) - self._poll_timer.setInterval(_POLL_INTERVAL_MS) - self._poll_timer.timeout.connect(self._poll) - self._poll_timer.start() - - # Immediate first check - QtCore.QTimer.singleShot(0, self._check_now) - - def _check_now(self): - """Run an immediate connectivity check and update the view.""" - self._silo_url = _get_silo_base_url() - connected = _check_connectivity(self._silo_url) - self._set_connected(connected) - - def _poll(self): - """Periodic connectivity check.""" - self._silo_url = _get_silo_base_url() - connected = _check_connectivity(self._silo_url) - self._set_connected(connected) - - def _set_connected(self, connected: bool): - if connected == self._connected: - return - self._connected = connected - if connected and self._web_view: - self._web_view.setUrl(QtCore.QUrl(self._silo_url)) - self._stack.setCurrentIndex(0) - FreeCAD.Console.PrintLog(f"Silo Start: connected to {self._silo_url}\n") - else: - self._offline.update_status(False, self._silo_url) - self._offline.refresh_recent_files() - self._stack.setCurrentIndex(1) - if not connected: - FreeCAD.Console.PrintLog(f"Silo Start: cannot reach {self._silo_url}\n") - self._offline.update_status(connected, self._silo_url) + def closeEvent(self, event): + self._stop_sse() + self._poll_timer.stop() + super().closeEvent(event) # --------------------------------------------------------------------------- -- 2.49.1