Compare commits
4 Commits
feat/silo-
...
fix/pull-p
| Author | SHA1 | Date | |
|---|---|---|---|
| e83769090b | |||
| 6c9789fdf3 | |||
| 85bfb17854 | |||
| 8937cb5e8b |
@@ -76,3 +76,22 @@ try:
|
|||||||
silo_start.register()
|
silo_start.register()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintWarning(f"Silo Start page override failed: {e}\n")
|
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)
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ from silo_client import (
|
|||||||
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
||||||
|
|
||||||
# Configuration - preferences take priority over env vars
|
# Configuration - preferences take priority over env vars
|
||||||
SILO_PROJECTS_DIR = os.environ.get(
|
SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects"))
|
||||||
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -66,9 +64,7 @@ class FreeCADSiloSettings(SiloSettings):
|
|||||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||||
return param.GetString("SslCertPath", "")
|
return param.GetString("SslCertPath", "")
|
||||||
|
|
||||||
def save_auth(
|
def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""):
|
||||||
self, username: str, role: str = "", source: str = "", token: str = ""
|
|
||||||
):
|
|
||||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||||
param.SetString("AuthUsername", username)
|
param.SetString("AuthUsername", username)
|
||||||
param.SetString("AuthRole", role)
|
param.SetString("AuthRole", role)
|
||||||
@@ -126,9 +122,7 @@ def _get_ssl_verify() -> bool:
|
|||||||
def _get_ssl_context():
|
def _get_ssl_context():
|
||||||
from silo_client._ssl import build_ssl_context
|
from silo_client._ssl import build_ssl_context
|
||||||
|
|
||||||
return build_ssl_context(
|
return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path())
|
||||||
_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_auth_headers() -> Dict[str, str]:
|
def _get_auth_headers() -> Dict[str, str]:
|
||||||
@@ -185,9 +179,7 @@ def _fetch_server_mode() -> str:
|
|||||||
# Icon helper
|
# Icon helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_ICON_DIR = os.path.join(
|
_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons")
|
||||||
os.path.dirname(os.path.abspath(__file__)), "resources", "icons"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _icon(name):
|
def _icon(name):
|
||||||
@@ -557,6 +549,35 @@ class SiloSync:
|
|||||||
_sync = 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
|
# COMMANDS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -642,11 +663,7 @@ class Silo_Open:
|
|||||||
try:
|
try:
|
||||||
for item in search_local_files(search_term):
|
for item in search_local_files(search_term):
|
||||||
existing = next(
|
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,
|
None,
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
@@ -670,12 +687,8 @@ class Silo_Open:
|
|||||||
|
|
||||||
results_table.setRowCount(len(results_data))
|
results_table.setRowCount(len(results_data))
|
||||||
for row, data in enumerate(results_data):
|
for row, data in enumerate(results_data):
|
||||||
results_table.setItem(
|
results_table.setItem(row, 0, QtGui.QTableWidgetItem(data["part_number"]))
|
||||||
row, 0, QtGui.QTableWidgetItem(data["part_number"])
|
results_table.setItem(row, 1, QtGui.QTableWidgetItem(data["description"]))
|
||||||
)
|
|
||||||
results_table.setItem(
|
|
||||||
row, 1, QtGui.QTableWidgetItem(data["description"])
|
|
||||||
)
|
|
||||||
results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"]))
|
results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"]))
|
||||||
results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"]))
|
results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"]))
|
||||||
results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"]))
|
results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"]))
|
||||||
@@ -741,13 +754,9 @@ class Silo_New:
|
|||||||
try:
|
try:
|
||||||
schema = _client.get_schema()
|
schema = _client.get_schema()
|
||||||
categories = schema.get("segments", [])
|
categories = schema.get("segments", [])
|
||||||
cat_segment = next(
|
cat_segment = next((s for s in categories if s.get("name") == "category"), None)
|
||||||
(s for s in categories if s.get("name") == "category"), None
|
|
||||||
)
|
|
||||||
if cat_segment and cat_segment.get("values"):
|
if cat_segment and cat_segment.get("values"):
|
||||||
cat_list = [
|
cat_list = [f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())]
|
||||||
f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())
|
|
||||||
]
|
|
||||||
category_str, ok = QtGui.QInputDialog.getItem(
|
category_str, ok = QtGui.QInputDialog.getItem(
|
||||||
None, "New Item", "Category:", cat_list, 0, False
|
None, "New Item", "Category:", cat_list, 0, False
|
||||||
)
|
)
|
||||||
@@ -755,15 +764,11 @@ class Silo_New:
|
|||||||
return
|
return
|
||||||
category = category_str.split(" - ")[0]
|
category = category_str.split(" - ")[0]
|
||||||
else:
|
else:
|
||||||
category, ok = QtGui.QInputDialog.getText(
|
category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:")
|
||||||
None, "New Item", "Category code:"
|
|
||||||
)
|
|
||||||
if not ok:
|
if not ok:
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
category, ok = QtGui.QInputDialog.getText(
|
category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:")
|
||||||
None, "New Item", "Category code:"
|
|
||||||
)
|
|
||||||
if not ok:
|
if not ok:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -808,9 +813,7 @@ class Silo_New:
|
|||||||
ok_btn.clicked.connect(dialog.accept)
|
ok_btn.clicked.connect(dialog.accept)
|
||||||
|
|
||||||
if dialog.exec_() == QtGui.QDialog.Accepted:
|
if dialog.exec_() == QtGui.QDialog.Accepted:
|
||||||
selected_projects = [
|
selected_projects = [item.text() for item in list_widget.selectedItems()]
|
||||||
item.text() for item in list_widget.selectedItems()
|
|
||||||
]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n")
|
FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n")
|
||||||
|
|
||||||
@@ -917,9 +920,7 @@ class Silo_Save:
|
|||||||
|
|
||||||
# Try to upload to MinIO
|
# Try to upload to MinIO
|
||||||
try:
|
try:
|
||||||
result = _client._upload_file(
|
result = _client._upload_file(part_number, str(file_path), properties, "Auto-save")
|
||||||
part_number, str(file_path), properties, "Auto-save"
|
|
||||||
)
|
|
||||||
|
|
||||||
new_rev = result["revision_number"]
|
new_rev = result["revision_number"]
|
||||||
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
|
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
|
||||||
@@ -952,9 +953,7 @@ class Silo_Commit:
|
|||||||
|
|
||||||
obj = get_tracked_object(doc)
|
obj = get_tracked_object(doc)
|
||||||
if not obj:
|
if not obj:
|
||||||
FreeCAD.Console.PrintError(
|
FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n")
|
||||||
"No tracked object. Use 'New' to register first.\n"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
part_number = obj.SiloPartNumber
|
part_number = obj.SiloPartNumber
|
||||||
@@ -971,9 +970,7 @@ class Silo_Commit:
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
result = _client._upload_file(
|
result = _client._upload_file(part_number, str(file_path), properties, comment)
|
||||||
part_number, str(file_path), properties, comment
|
|
||||||
)
|
|
||||||
|
|
||||||
new_rev = result["revision_number"]
|
new_rev = result["revision_number"]
|
||||||
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
|
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
|
||||||
@@ -993,8 +990,10 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
|
|||||||
conflicts = []
|
conflicts = []
|
||||||
|
|
||||||
# Check for unsaved changes in an open document
|
# Check for unsaved changes in an open document
|
||||||
if doc is not None and doc.IsModified():
|
if doc is not None:
|
||||||
conflicts.append("Document has unsaved local changes.")
|
gui_doc = FreeCADGui.getDocument(doc.Name) if doc.Name else None
|
||||||
|
if gui_doc and gui_doc.Modified:
|
||||||
|
conflicts.append("Document has unsaved local changes.")
|
||||||
|
|
||||||
# Check local revision vs server latest
|
# Check local revision vs server latest
|
||||||
if doc is not None:
|
if doc is not None:
|
||||||
@@ -1020,9 +1019,7 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
|
|||||||
server_updated = item.get("updated_at", "")
|
server_updated = item.get("updated_at", "")
|
||||||
if server_updated:
|
if server_updated:
|
||||||
# Parse ISO format timestamp
|
# Parse ISO format timestamp
|
||||||
server_dt = datetime.datetime.fromisoformat(
|
server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00"))
|
||||||
server_updated.replace("Z", "+00:00")
|
|
||||||
)
|
|
||||||
if server_dt > local_mtime:
|
if server_dt > local_mtime:
|
||||||
conflicts.append("Server version is newer than local file.")
|
conflicts.append("Server version is newer than local file.")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1052,9 +1049,7 @@ class SiloPullDialog:
|
|||||||
# Revision table
|
# Revision table
|
||||||
self._table = QtGui.QTableWidget()
|
self._table = QtGui.QTableWidget()
|
||||||
self._table.setColumnCount(5)
|
self._table.setColumnCount(5)
|
||||||
self._table.setHorizontalHeaderLabels(
|
self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"])
|
||||||
["Rev", "Date", "Comment", "Status", "File"]
|
|
||||||
)
|
|
||||||
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||||
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||||
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||||
@@ -1168,18 +1163,14 @@ class Silo_Pull:
|
|||||||
|
|
||||||
if not has_any_file:
|
if not has_any_file:
|
||||||
if existing_local:
|
if existing_local:
|
||||||
FreeCAD.Console.PrintMessage(
|
FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n")
|
||||||
f"Opening existing local file: {existing_local}\n"
|
|
||||||
)
|
|
||||||
FreeCAD.openDocument(str(existing_local))
|
FreeCAD.openDocument(str(existing_local))
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
item = _client.get_item(part_number)
|
item = _client.get_item(part_number)
|
||||||
new_doc = _sync.create_document_for_item(item, save=True)
|
new_doc = _sync.create_document_for_item(item, save=True)
|
||||||
if new_doc:
|
if new_doc:
|
||||||
FreeCAD.Console.PrintMessage(
|
FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n")
|
||||||
f"Created local file for {part_number}\n"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
QtGui.QMessageBox.warning(
|
QtGui.QMessageBox.warning(
|
||||||
None,
|
None,
|
||||||
@@ -1224,7 +1215,7 @@ class Silo_Pull:
|
|||||||
progress = QtGui.QProgressDialog(
|
progress = QtGui.QProgressDialog(
|
||||||
f"Downloading {part_number} rev {rev_num}...", "Cancel", 0, 100
|
f"Downloading {part_number} rev {rev_num}...", "Cancel", 0, 100
|
||||||
)
|
)
|
||||||
progress.setWindowModality(2) # Qt.WindowModal
|
progress.setWindowModality(QtCore.Qt.WindowModal)
|
||||||
progress.setMinimumDuration(0)
|
progress.setMinimumDuration(0)
|
||||||
progress.setValue(0)
|
progress.setValue(0)
|
||||||
|
|
||||||
@@ -1310,9 +1301,7 @@ class Silo_Push:
|
|||||||
server_dt = datetime.fromisoformat(
|
server_dt = datetime.fromisoformat(
|
||||||
server_time_str.replace("Z", "+00:00")
|
server_time_str.replace("Z", "+00:00")
|
||||||
)
|
)
|
||||||
local_dt = datetime.fromtimestamp(
|
local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc)
|
||||||
local_mtime, tz=timezone.utc
|
|
||||||
)
|
|
||||||
if local_dt > server_dt:
|
if local_dt > server_dt:
|
||||||
unuploaded.append(lf)
|
unuploaded.append(lf)
|
||||||
else:
|
else:
|
||||||
@@ -1325,9 +1314,7 @@ class Silo_Push:
|
|||||||
pass # Not in DB, skip
|
pass # Not in DB, skip
|
||||||
|
|
||||||
if not unuploaded:
|
if not unuploaded:
|
||||||
QtGui.QMessageBox.information(
|
QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.")
|
||||||
None, "Push", "All local files are already uploaded."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
msg = f"Found {len(unuploaded)} files to upload:\n\n"
|
msg = f"Found {len(unuploaded)} files to upload:\n\n"
|
||||||
@@ -1345,9 +1332,7 @@ class Silo_Push:
|
|||||||
|
|
||||||
uploaded = 0
|
uploaded = 0
|
||||||
for item in unuploaded:
|
for item in unuploaded:
|
||||||
result = _sync.upload_file(
|
result = _sync.upload_file(item["part_number"], item["path"], "Synced from local")
|
||||||
item["part_number"], item["path"], "Synced from local"
|
|
||||||
)
|
|
||||||
if result:
|
if result:
|
||||||
uploaded += 1
|
uploaded += 1
|
||||||
|
|
||||||
@@ -1396,7 +1381,9 @@ class Silo_Info:
|
|||||||
msg = f"<h3>{part_number}</h3>"
|
msg = f"<h3>{part_number}</h3>"
|
||||||
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
|
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
|
||||||
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
|
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
|
||||||
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
|
msg += (
|
||||||
|
f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
|
||||||
|
)
|
||||||
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
|
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
|
||||||
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
|
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
|
||||||
|
|
||||||
@@ -1462,9 +1449,7 @@ class Silo_TagProjects:
|
|||||||
try:
|
try:
|
||||||
# Get current projects for item
|
# Get current projects for item
|
||||||
current_projects = _client.get_item_projects(part_number)
|
current_projects = _client.get_item_projects(part_number)
|
||||||
current_codes = {
|
current_codes = {p.get("code", "") for p in current_projects if p.get("code")}
|
||||||
p.get("code", "") for p in current_projects if p.get("code")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get all available projects
|
# Get all available projects
|
||||||
all_projects = _client.get_projects()
|
all_projects = _client.get_projects()
|
||||||
@@ -1575,9 +1560,7 @@ class Silo_Rollback:
|
|||||||
dialog.setMinimumHeight(300)
|
dialog.setMinimumHeight(300)
|
||||||
layout = QtGui.QVBoxLayout(dialog)
|
layout = QtGui.QVBoxLayout(dialog)
|
||||||
|
|
||||||
label = QtGui.QLabel(
|
label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):")
|
||||||
f"Select a revision to rollback to (current: Rev {current_rev}):"
|
|
||||||
)
|
|
||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
|
|
||||||
# Revision table
|
# Revision table
|
||||||
@@ -1592,12 +1575,8 @@ class Silo_Rollback:
|
|||||||
for i, rev in enumerate(prev_revisions):
|
for i, rev in enumerate(prev_revisions):
|
||||||
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
|
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
|
||||||
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
|
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
|
||||||
table.setItem(
|
table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]))
|
||||||
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])
|
table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or ""))
|
||||||
)
|
|
||||||
table.setItem(
|
|
||||||
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
|
|
||||||
)
|
|
||||||
|
|
||||||
table.resizeColumnsToContents()
|
table.resizeColumnsToContents()
|
||||||
layout.addWidget(table)
|
layout.addWidget(table)
|
||||||
@@ -1623,9 +1602,7 @@ class Silo_Rollback:
|
|||||||
def on_rollback():
|
def on_rollback():
|
||||||
selected = table.selectedItems()
|
selected = table.selectedItems()
|
||||||
if not selected:
|
if not selected:
|
||||||
QtGui.QMessageBox.warning(
|
QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision")
|
||||||
dialog, "Rollback", "Please select a revision"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
|
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
|
||||||
dialog.accept()
|
dialog.accept()
|
||||||
@@ -1723,9 +1700,7 @@ class Silo_SetStatus:
|
|||||||
# Update status
|
# Update status
|
||||||
_client.update_revision(part_number, rev_num, status=status)
|
_client.update_revision(part_number, rev_num, status=status)
|
||||||
|
|
||||||
FreeCAD.Console.PrintMessage(
|
FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n")
|
||||||
f"Updated Rev {rev_num} status to '{status}'\n"
|
|
||||||
)
|
|
||||||
QtGui.QMessageBox.information(
|
QtGui.QMessageBox.information(
|
||||||
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
|
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
|
||||||
)
|
)
|
||||||
@@ -1789,9 +1764,7 @@ class Silo_Settings:
|
|||||||
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
|
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
|
||||||
layout.addWidget(ssl_checkbox)
|
layout.addWidget(ssl_checkbox)
|
||||||
|
|
||||||
ssl_hint = QtGui.QLabel(
|
ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.")
|
||||||
"Disable only for internal servers with self-signed certificates."
|
|
||||||
)
|
|
||||||
ssl_hint.setWordWrap(True)
|
ssl_hint.setWordWrap(True)
|
||||||
ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
|
ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
|
||||||
layout.addWidget(ssl_hint)
|
layout.addWidget(ssl_hint)
|
||||||
@@ -2068,9 +2041,7 @@ class Silo_BOM:
|
|||||||
|
|
||||||
wu_table = QtGui.QTableWidget()
|
wu_table = QtGui.QTableWidget()
|
||||||
wu_table.setColumnCount(5)
|
wu_table.setColumnCount(5)
|
||||||
wu_table.setHorizontalHeaderLabels(
|
wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"])
|
||||||
["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
|
|
||||||
)
|
|
||||||
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||||
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||||
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||||
@@ -2099,16 +2070,12 @@ class Silo_BOM:
|
|||||||
bom_table.setItem(
|
bom_table.setItem(
|
||||||
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
|
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
|
||||||
)
|
)
|
||||||
bom_table.setItem(
|
bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
|
||||||
row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
|
|
||||||
)
|
|
||||||
qty = entry.get("quantity")
|
qty = entry.get("quantity")
|
||||||
bom_table.setItem(
|
bom_table.setItem(
|
||||||
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
||||||
)
|
)
|
||||||
bom_table.setItem(
|
bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or ""))
|
||||||
row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
|
|
||||||
)
|
|
||||||
ref_des = entry.get("reference_designators") or []
|
ref_des = entry.get("reference_designators") or []
|
||||||
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
||||||
bom_table.setItem(
|
bom_table.setItem(
|
||||||
@@ -2130,16 +2097,12 @@ class Silo_BOM:
|
|||||||
wu_table.setItem(
|
wu_table.setItem(
|
||||||
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
|
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
|
||||||
)
|
)
|
||||||
wu_table.setItem(
|
wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
|
||||||
row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
|
|
||||||
)
|
|
||||||
qty = entry.get("quantity")
|
qty = entry.get("quantity")
|
||||||
wu_table.setItem(
|
wu_table.setItem(
|
||||||
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
|
||||||
)
|
)
|
||||||
wu_table.setItem(
|
wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or ""))
|
||||||
row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
|
|
||||||
)
|
|
||||||
ref_des = entry.get("reference_designators") or []
|
ref_des = entry.get("reference_designators") or []
|
||||||
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
|
||||||
wu_table.resizeColumnsToContents()
|
wu_table.resizeColumnsToContents()
|
||||||
@@ -2192,9 +2155,7 @@ class Silo_BOM:
|
|||||||
try:
|
try:
|
||||||
qty = float(qty_text)
|
qty = float(qty_text)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
QtGui.QMessageBox.warning(
|
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
|
||||||
dialog, "BOM", "Quantity must be a number."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
unit = unit_input.text().strip() or None
|
unit = unit_input.text().strip() or None
|
||||||
@@ -2273,9 +2234,7 @@ class Silo_BOM:
|
|||||||
try:
|
try:
|
||||||
new_qty = float(qty_text)
|
new_qty = float(qty_text)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
QtGui.QMessageBox.warning(
|
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
|
||||||
dialog, "BOM", "Quantity must be a number."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
new_unit = unit_input.text().strip() or None
|
new_unit = unit_input.text().strip() or None
|
||||||
@@ -2299,9 +2258,7 @@ class Silo_BOM:
|
|||||||
)
|
)
|
||||||
load_bom()
|
load_bom()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
QtGui.QMessageBox.warning(
|
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}")
|
||||||
dialog, "BOM", f"Failed to update entry:\n{exc}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_remove():
|
def on_remove():
|
||||||
selected = bom_table.selectedItems()
|
selected = bom_table.selectedItems()
|
||||||
@@ -2327,9 +2284,7 @@ class Silo_BOM:
|
|||||||
_client.delete_bom_entry(part_number, child_pn)
|
_client.delete_bom_entry(part_number, child_pn)
|
||||||
load_bom()
|
load_bom()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
QtGui.QMessageBox.warning(
|
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}")
|
||||||
dialog, "BOM", f"Failed to remove entry:\n{exc}"
|
|
||||||
)
|
|
||||||
|
|
||||||
add_btn.clicked.connect(on_add)
|
add_btn.clicked.connect(on_add)
|
||||||
edit_btn.clicked.connect(on_edit)
|
edit_btn.clicked.connect(on_edit)
|
||||||
@@ -2368,9 +2323,7 @@ class SiloEventListener(QtCore.QThread):
|
|||||||
|
|
||||||
item_updated = QtCore.Signal(str) # part_number
|
item_updated = QtCore.Signal(str) # part_number
|
||||||
revision_created = QtCore.Signal(str, int) # part_number, revision
|
revision_created = QtCore.Signal(str, int) # part_number, revision
|
||||||
connection_status = QtCore.Signal(
|
connection_status = QtCore.Signal(str, int, str) # (status, retry_count, error_message)
|
||||||
str, int, str
|
|
||||||
) # (status, retry_count, error_message)
|
|
||||||
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
|
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
|
||||||
|
|
||||||
_MAX_RETRIES = 10
|
_MAX_RETRIES = 10
|
||||||
@@ -2437,9 +2390,7 @@ class SiloEventListener(QtCore.QThread):
|
|||||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._response = urllib.request.urlopen(
|
self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90)
|
||||||
req, context=_get_ssl_context(), timeout=90
|
|
||||||
)
|
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code in (404, 501):
|
if e.code in (404, 501):
|
||||||
raise _SSEUnsupported()
|
raise _SSEUnsupported()
|
||||||
@@ -2720,9 +2671,7 @@ class SiloAuthDockWidget:
|
|||||||
self._sse_label.setToolTip("")
|
self._sse_label.setToolTip("")
|
||||||
FreeCAD.Console.PrintMessage("Silo: SSE connected\n")
|
FreeCAD.Console.PrintMessage("Silo: SSE connected\n")
|
||||||
elif status == "disconnected":
|
elif status == "disconnected":
|
||||||
self._sse_label.setText(
|
self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...")
|
||||||
f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..."
|
|
||||||
)
|
|
||||||
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
|
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
|
||||||
self._sse_label.setToolTip(error or "Connection lost")
|
self._sse_label.setToolTip(error or "Connection lost")
|
||||||
FreeCAD.Console.PrintWarning(
|
FreeCAD.Console.PrintWarning(
|
||||||
@@ -2732,9 +2681,7 @@ class SiloAuthDockWidget:
|
|||||||
self._sse_label.setText("Disconnected")
|
self._sse_label.setText("Disconnected")
|
||||||
self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;")
|
self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;")
|
||||||
self._sse_label.setToolTip(error or "Max retries reached")
|
self._sse_label.setToolTip(error or "Max retries reached")
|
||||||
FreeCAD.Console.PrintError(
|
FreeCAD.Console.PrintError(f"Silo: SSE gave up after {retry} retries: {error}\n")
|
||||||
f"Silo: SSE gave up after {retry} retries: {error}\n"
|
|
||||||
)
|
|
||||||
elif status == "unsupported":
|
elif status == "unsupported":
|
||||||
self._sse_label.setText("Not available")
|
self._sse_label.setText("Not available")
|
||||||
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
|
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
|
||||||
@@ -2776,14 +2723,10 @@ class SiloAuthDockWidget:
|
|||||||
self._refresh_activity_panel()
|
self._refresh_activity_panel()
|
||||||
|
|
||||||
def _on_remote_revision(self, part_number, revision):
|
def _on_remote_revision(self, part_number, revision):
|
||||||
FreeCAD.Console.PrintMessage(
|
FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n")
|
||||||
f"Silo: New revision {revision} for {part_number}\n"
|
|
||||||
)
|
|
||||||
mw = FreeCADGui.getMainWindow()
|
mw = FreeCADGui.getMainWindow()
|
||||||
if mw is not None:
|
if mw is not None:
|
||||||
mw.statusBar().showMessage(
|
mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000)
|
||||||
f"Silo: {part_number} rev {revision} available", 5000
|
|
||||||
)
|
|
||||||
self._refresh_activity_panel()
|
self._refresh_activity_panel()
|
||||||
|
|
||||||
def _refresh_activity_panel(self):
|
def _refresh_activity_panel(self):
|
||||||
@@ -2849,9 +2792,7 @@ class SiloAuthDockWidget:
|
|||||||
rev_part = f" \u2013 Rev {rev_num}" if rev_num else ""
|
rev_part = f" \u2013 Rev {rev_num}" if rev_num else ""
|
||||||
date_part = f" \u2013 {updated}" if updated else ""
|
date_part = f" \u2013 {updated}" if updated else ""
|
||||||
local_badge = " \u25cf local" if pn in local_pns else ""
|
local_badge = " \u25cf local" if pn in local_pns else ""
|
||||||
line1 = (
|
line1 = f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}"
|
||||||
f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if comment:
|
if comment:
|
||||||
line1 += f'\n "{comment}"'
|
line1 += f'\n "{comment}"'
|
||||||
@@ -3307,9 +3248,7 @@ class Silo_StartPanel:
|
|||||||
dock = QtGui.QDockWidget("Silo", mw)
|
dock = QtGui.QDockWidget("Silo", mw)
|
||||||
dock.setObjectName("SiloStartPanel")
|
dock.setObjectName("SiloStartPanel")
|
||||||
dock.setWidget(content.widget)
|
dock.setWidget(content.widget)
|
||||||
dock.setAllowedAreas(
|
dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
|
||||||
QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea
|
|
||||||
)
|
|
||||||
mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
|
mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
|
||||||
|
|
||||||
def IsActive(self):
|
def IsActive(self):
|
||||||
@@ -3343,9 +3282,7 @@ class _DiagWorker(QtCore.QThread):
|
|||||||
self.result.emit("DNS", False, "no hostname in URL")
|
self.result.emit("DNS", False, "no hostname in URL")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
addrs = socket.getaddrinfo(
|
addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||||
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
|
|
||||||
)
|
|
||||||
first_ip = addrs[0][4][0] if addrs else "?"
|
first_ip = addrs[0][4][0] if addrs else "?"
|
||||||
self.result.emit("DNS", True, f"{hostname} -> {first_ip}")
|
self.result.emit("DNS", True, f"{hostname} -> {first_ip}")
|
||||||
except socket.gaierror as e:
|
except socket.gaierror as e:
|
||||||
|
|||||||
@@ -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:
|
Replaces the default Start page with a rich native panel that fetches data
|
||||||
- A QWebEngineView showing the Silo web app (when Silo is reachable)
|
from the Silo REST API, shows real-time activity via SSE, and provides quick
|
||||||
- A native Qt offline fallback with recent files and connectivity status
|
access to database items and recent local files.
|
||||||
|
|
||||||
The command override is activated by calling ``register()`` at module level
|
The command override is activated by calling ``register()`` at module level
|
||||||
from InitGui.py, which overwrites the C++ ``Start_Start`` command.
|
from InitGui.py, which overwrites the C++ ``Start_Start`` command.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import ssl
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -20,17 +16,6 @@ import FreeCAD
|
|||||||
import FreeCADGui
|
import FreeCADGui
|
||||||
from PySide import QtCore, QtGui, QtWidgets
|
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
|
# Catppuccin Mocha palette
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -53,8 +38,6 @@ _MOCHA = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
_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:
|
if not url:
|
||||||
url = os.environ.get("SILO_API_URL", "http://localhost:8080/api")
|
url = os.environ.get("SILO_API_URL", "http://localhost:8080/api")
|
||||||
url = url.rstrip("/")
|
url = url.rstrip("/")
|
||||||
# Strip trailing /api to get the web root
|
|
||||||
if url.endswith("/api"):
|
if url.endswith("/api"):
|
||||||
url = url[:-4]
|
url = url[:-4]
|
||||||
return url
|
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:
|
def _get_recent_files() -> list:
|
||||||
"""Read recent files from FreeCAD preferences."""
|
"""Read recent files from FreeCAD preferences."""
|
||||||
group = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/RecentFiles")
|
group = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/RecentFiles")
|
||||||
@@ -111,63 +71,285 @@ def _get_recent_files() -> list:
|
|||||||
return files
|
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):
|
class SiloStartView(QtWidgets.QWidget):
|
||||||
"""Native Qt fallback showing Silo status and recent local files."""
|
"""Native Qt start page with Silo database items, recent files, and
|
||||||
|
real-time activity feed."""
|
||||||
retry_clicked = QtCore.Signal()
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
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._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):
|
def _build_ui(self):
|
||||||
layout = QtWidgets.QVBoxLayout(self)
|
root = QtWidgets.QVBoxLayout(self)
|
||||||
layout.setContentsMargins(40, 30, 40, 20)
|
root.setContentsMargins(32, 24, 32, 16)
|
||||||
layout.setSpacing(0)
|
root.setSpacing(0)
|
||||||
|
|
||||||
# --- Status banner ---
|
# --- Status banner ---
|
||||||
banner = QtWidgets.QFrame()
|
banner = QtWidgets.QFrame()
|
||||||
banner.setObjectName("SiloStatusBanner")
|
banner.setObjectName("SiloStatusBanner")
|
||||||
banner_layout = QtWidgets.QHBoxLayout(banner)
|
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 = QtWidgets.QLabel()
|
||||||
self._status_icon.setFixedSize(12, 12)
|
self._status_icon.setFixedSize(12, 12)
|
||||||
banner_layout.addWidget(self._status_icon)
|
banner_layout.addWidget(self._status_icon)
|
||||||
|
|
||||||
self._status_label = QtWidgets.QLabel()
|
self._status_label = QtWidgets.QLabel("Checking Silo connection...")
|
||||||
self._status_label.setWordWrap(True)
|
self._status_label.setWordWrap(True)
|
||||||
banner_layout.addWidget(self._status_label, 1)
|
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 = QtWidgets.QPushButton("Retry")
|
||||||
self._retry_btn.setFixedWidth(80)
|
self._retry_btn.setFixedWidth(70)
|
||||||
self._retry_btn.setCursor(QtCore.Qt.PointingHandCursor)
|
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)
|
banner_layout.addWidget(self._retry_btn)
|
||||||
|
|
||||||
layout.addWidget(banner)
|
root.addWidget(banner)
|
||||||
layout.addSpacing(24)
|
root.addSpacing(20)
|
||||||
|
|
||||||
# --- Recent files header ---
|
# --- Main content: items (left) + recent files (right) ---
|
||||||
header = QtWidgets.QLabel("Recent Files")
|
content_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
|
||||||
header.setObjectName("SiloRecentHeader")
|
content_splitter.setHandleWidth(12)
|
||||||
layout.addWidget(header)
|
content_splitter.setStyleSheet(
|
||||||
layout.addSpacing(12)
|
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 = QtWidgets.QListWidget()
|
||||||
self._file_list.setObjectName("SiloRecentList")
|
self._file_list.setProperty("class", "SiloList")
|
||||||
self._file_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
self._file_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||||
self._file_list.itemDoubleClicked.connect(self._on_file_clicked)
|
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 ---
|
||||||
footer = QtWidgets.QHBoxLayout()
|
footer = QtWidgets.QHBoxLayout()
|
||||||
@@ -178,103 +360,234 @@ class _OfflineWidget(QtWidgets.QWidget):
|
|||||||
self._startup_cb.setChecked(not show)
|
self._startup_cb.setChecked(not show)
|
||||||
self._startup_cb.toggled.connect(self._on_startup_toggled)
|
self._startup_cb.toggled.connect(self._on_startup_toggled)
|
||||||
footer.addWidget(self._startup_cb)
|
footer.addWidget(self._startup_cb)
|
||||||
layout.addLayout(footer)
|
root.addLayout(footer)
|
||||||
|
|
||||||
self._apply_style()
|
# -- data loading -------------------------------------------------------
|
||||||
|
|
||||||
def _apply_style(self):
|
def _initial_load(self):
|
||||||
self.setStyleSheet(f"""
|
"""First-time data load and SSE connection."""
|
||||||
_OfflineWidget {{
|
self._refresh_status()
|
||||||
background-color: {_MOCHA["base"]};
|
self._refresh_items()
|
||||||
}}
|
self._refresh_recent_files()
|
||||||
#SiloStatusBanner {{
|
self._start_sse()
|
||||||
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 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:
|
if connected:
|
||||||
self._status_icon.setStyleSheet(
|
self._status_icon.setStyleSheet(
|
||||||
f"background-color: {_MOCHA['green']}; border-radius: 6px;"
|
f"background-color: {_MOCHA['green']}; border-radius: 6px;"
|
||||||
)
|
)
|
||||||
self._status_label.setText(f"Silo connected — {url}")
|
|
||||||
self._retry_btn.hide()
|
self._retry_btn.hide()
|
||||||
|
self._browser_btn.show()
|
||||||
else:
|
else:
|
||||||
self._status_icon.setStyleSheet(
|
self._status_icon.setStyleSheet(
|
||||||
f"background-color: {_MOCHA['red']}; border-radius: 6px;"
|
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._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()
|
self._file_list.clear()
|
||||||
files = _get_recent_files()
|
files = _get_recent_files()
|
||||||
if not files:
|
if not files:
|
||||||
item = QtWidgets.QListWidgetItem("No recent files")
|
item = QtWidgets.QListWidgetItem("(No recent files)")
|
||||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||||
self._file_list.addItem(item)
|
self._file_list.addItem(item)
|
||||||
return
|
return
|
||||||
for f in files:
|
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 = QtWidgets.QListWidgetItem(label)
|
||||||
item.setData(QtCore.Qt.UserRole, f["path"])
|
item.setData(QtCore.Qt.UserRole, f["path"])
|
||||||
|
item.setToolTip(f["path"])
|
||||||
self._file_list.addItem(item)
|
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):
|
def _on_file_clicked(self, item: QtWidgets.QListWidgetItem):
|
||||||
path = item.data(QtCore.Qt.UserRole)
|
path = item.data(QtCore.Qt.UserRole)
|
||||||
if path:
|
if path:
|
||||||
@@ -283,112 +596,22 @@ class _OfflineWidget(QtWidgets.QWidget):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Silo Start: failed to open {path}: {e}\n")
|
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
|
@staticmethod
|
||||||
def _on_startup_toggled(checked: bool):
|
def _on_startup_toggled(checked: bool):
|
||||||
prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Start")
|
prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Start")
|
||||||
prefs.SetBool("ShowOnStartup", not checked)
|
prefs.SetBool("ShowOnStartup", not checked)
|
||||||
|
|
||||||
|
# -- cleanup ------------------------------------------------------------
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def closeEvent(self, event):
|
||||||
# Web engine page (navigation filter)
|
self._stop_sse()
|
||||||
# ---------------------------------------------------------------------------
|
self._poll_timer.stop()
|
||||||
|
super().closeEvent(event)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user