Compare commits
9 Commits
fix/delete
...
fix/pull-p
| Author | SHA1 | Date | |
|---|---|---|---|
| e83769090b | |||
| 6c9789fdf3 | |||
| 85bfb17854 | |||
| 8937cb5e8b | |||
| a53cd52c73 | |||
|
|
c6e187a75c | ||
|
|
2e9bf52082 | ||
|
|
383eefce9c | ||
|
|
1676b3e1a0 |
@@ -67,3 +67,31 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
|||||||
|
|
||||||
FreeCADGui.addWorkbench(SiloWorkbench())
|
FreeCADGui.addWorkbench(SiloWorkbench())
|
||||||
FreeCAD.Console.PrintMessage("Silo workbench registered\n")
|
FreeCAD.Console.PrintMessage("Silo workbench registered\n")
|
||||||
|
|
||||||
|
# Override the Start page with Silo-aware version (must happen before
|
||||||
|
# the C++ StartLauncher fires at ~100ms after GUI init)
|
||||||
|
try:
|
||||||
|
import silo_start
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
8
freecad/resources/icons/silo-rollback.svg
Normal file
8
freecad/resources/icons/silo-rollback.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Counter-clockwise arrow -->
|
||||||
|
<polyline points="1 4 1 10 7 10" stroke="#f38ba8"/>
|
||||||
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" stroke="#cba6f7"/>
|
||||||
|
<!-- Clock hands -->
|
||||||
|
<line x1="12" y1="7" x2="12" y2="12" stroke="#89dceb" stroke-width="1.5"/>
|
||||||
|
<line x1="12" y1="12" x2="15" y2="14" stroke="#89dceb" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 493 B |
7
freecad/resources/icons/silo-status.svg
Normal file
7
freecad/resources/icons/silo-status.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Shield shape -->
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" fill="#313244"/>
|
||||||
|
<!-- Status bars -->
|
||||||
|
<line x1="8" y1="10" x2="16" y2="10" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||||
|
<line x1="8" y1="14" x2="13" y2="14" stroke="#89dceb" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
6
freecad/resources/icons/silo-tag.svg
Normal file
6
freecad/resources/icons/silo-tag.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Tag shape -->
|
||||||
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" fill="#313244"/>
|
||||||
|
<!-- Tag hole -->
|
||||||
|
<circle cx="7" cy="7" r="1.5" fill="#cba6f7" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 373 B |
@@ -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)
|
||||||
@@ -1998,15 +1971,18 @@ class Silo_BOM:
|
|||||||
|
|
||||||
obj = get_tracked_object(doc)
|
obj = get_tracked_object(doc)
|
||||||
if not obj:
|
if not obj:
|
||||||
FreeCAD.Console.PrintError("No tracked Silo item in active document.\n")
|
reply = QtGui.QMessageBox.question(
|
||||||
from PySide import QtGui as _qg
|
|
||||||
|
|
||||||
_qg.QMessageBox.warning(
|
|
||||||
None,
|
None,
|
||||||
"BOM",
|
"BOM",
|
||||||
"This document is not registered with Silo.\nUse Silo > New to register it first.",
|
"This document is not registered with Silo.\n\nRegister it now?",
|
||||||
|
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
||||||
)
|
)
|
||||||
return
|
if reply != QtGui.QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
FreeCADGui.runCommand("Silo_New")
|
||||||
|
obj = get_tracked_object(doc)
|
||||||
|
if not obj:
|
||||||
|
return
|
||||||
|
|
||||||
part_number = obj.SiloPartNumber
|
part_number = obj.SiloPartNumber
|
||||||
|
|
||||||
@@ -2065,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)
|
||||||
@@ -2096,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(
|
||||||
@@ -2127,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()
|
||||||
@@ -2189,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
|
||||||
@@ -2270,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
|
||||||
@@ -2296,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()
|
||||||
@@ -2324,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)
|
||||||
@@ -2365,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
|
||||||
@@ -2428,15 +2384,13 @@ class SiloEventListener(QtCore.QThread):
|
|||||||
# -- SSE stream reader --------------------------------------------------
|
# -- SSE stream reader --------------------------------------------------
|
||||||
|
|
||||||
def _listen(self):
|
def _listen(self):
|
||||||
url = f"{_get_api_url().rstrip('/')}/api/events"
|
url = f"{_get_api_url().rstrip('/')}/events"
|
||||||
headers = {"Accept": "text/event-stream"}
|
headers = {"Accept": "text/event-stream"}
|
||||||
headers.update(_get_auth_headers())
|
headers.update(_get_auth_headers())
|
||||||
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()
|
||||||
@@ -2717,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(
|
||||||
@@ -2729,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;")
|
||||||
@@ -2773,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):
|
||||||
@@ -2846,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}"'
|
||||||
@@ -3304,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):
|
||||||
@@ -3340,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:
|
||||||
|
|||||||
@@ -299,9 +299,7 @@ class SiloOrigin:
|
|||||||
Created App.Document or None
|
Created App.Document or None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd = FreeCADGui.Command.get("Silo_New")
|
FreeCADGui.runCommand("Silo_New")
|
||||||
if cmd:
|
|
||||||
cmd.Activated()
|
|
||||||
return FreeCAD.ActiveDocument
|
return FreeCAD.ActiveDocument
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Silo new document failed: {e}\n")
|
FreeCAD.Console.PrintError(f"Silo new document failed: {e}\n")
|
||||||
@@ -322,9 +320,7 @@ class SiloOrigin:
|
|||||||
if not identity:
|
if not identity:
|
||||||
# No identity - show search dialog
|
# No identity - show search dialog
|
||||||
try:
|
try:
|
||||||
cmd = FreeCADGui.Command.get("Silo_Open")
|
FreeCADGui.runCommand("Silo_Open")
|
||||||
if cmd:
|
|
||||||
cmd.Activated()
|
|
||||||
return FreeCAD.ActiveDocument
|
return FreeCAD.ActiveDocument
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Silo open failed: {e}\n")
|
FreeCAD.Console.PrintError(f"Silo open failed: {e}\n")
|
||||||
@@ -354,9 +350,7 @@ class SiloOrigin:
|
|||||||
Opened App.Document or None
|
Opened App.Document or None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd = FreeCADGui.Command.get("Silo_Open")
|
FreeCADGui.runCommand("Silo_Open")
|
||||||
if cmd:
|
|
||||||
cmd.Activated()
|
|
||||||
return FreeCAD.ActiveDocument
|
return FreeCAD.ActiveDocument
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Silo open failed: {e}\n")
|
FreeCAD.Console.PrintError(f"Silo open failed: {e}\n")
|
||||||
@@ -473,10 +467,8 @@ class SiloOrigin:
|
|||||||
True if command was executed
|
True if command was executed
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd = FreeCADGui.Command.get("Silo_Commit")
|
FreeCADGui.runCommand("Silo_Commit")
|
||||||
if cmd:
|
return True
|
||||||
cmd.Activated()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Silo commit failed: {e}\n")
|
FreeCAD.Console.PrintError(f"Silo commit failed: {e}\n")
|
||||||
return False
|
return False
|
||||||
@@ -493,10 +485,8 @@ class SiloOrigin:
|
|||||||
True if command was executed
|
True if command was executed
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd = FreeCADGui.Command.get("Silo_Pull")
|
FreeCADGui.runCommand("Silo_Pull")
|
||||||
if cmd:
|
return True
|
||||||
cmd.Activated()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Silo pull failed: {e}\n")
|
FreeCAD.Console.PrintError(f"Silo pull failed: {e}\n")
|
||||||
return False
|
return False
|
||||||
@@ -513,10 +503,8 @@ class SiloOrigin:
|
|||||||
True if command was executed
|
True if command was executed
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd = FreeCADGui.Command.get("Silo_Push")
|
FreeCADGui.runCommand("Silo_Push")
|
||||||
if cmd:
|
return True
|
||||||
cmd.Activated()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Silo push failed: {e}\n")
|
FreeCAD.Console.PrintError(f"Silo push failed: {e}\n")
|
||||||
return False
|
return False
|
||||||
@@ -530,9 +518,7 @@ class SiloOrigin:
|
|||||||
doc: FreeCAD App.Document
|
doc: FreeCAD App.Document
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd = FreeCADGui.Command.get("Silo_Info")
|
FreeCADGui.runCommand("Silo_Info")
|
||||||
if cmd:
|
|
||||||
cmd.Activated()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Silo info failed: {e}\n")
|
FreeCAD.Console.PrintError(f"Silo info failed: {e}\n")
|
||||||
|
|
||||||
@@ -545,9 +531,7 @@ class SiloOrigin:
|
|||||||
doc: FreeCAD App.Document
|
doc: FreeCAD App.Document
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cmd = FreeCADGui.Command.get("Silo_BOM")
|
FreeCADGui.runCommand("Silo_BOM")
|
||||||
if cmd:
|
|
||||||
cmd.Activated()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
FreeCAD.Console.PrintError(f"Silo BOM failed: {e}\n")
|
FreeCAD.Console.PrintError(f"Silo BOM failed: {e}\n")
|
||||||
|
|
||||||
|
|||||||
667
freecad/silo_start.py
Normal file
667
freecad/silo_start.py
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
"""Silo Start Page — native Qt start view for Kindred Create.
|
||||||
|
|
||||||
|
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
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import FreeCAD
|
||||||
|
import FreeCADGui
|
||||||
|
from PySide import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Catppuccin Mocha palette
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_MOCHA = {
|
||||||
|
"base": "#1e1e2e",
|
||||||
|
"mantle": "#181825",
|
||||||
|
"crust": "#11111b",
|
||||||
|
"surface0": "#313244",
|
||||||
|
"surface1": "#45475a",
|
||||||
|
"surface2": "#585b70",
|
||||||
|
"text": "#cdd6f4",
|
||||||
|
"subtext0": "#a6adc8",
|
||||||
|
"subtext1": "#bac2de",
|
||||||
|
"blue": "#89b4fa",
|
||||||
|
"green": "#a6e3a1",
|
||||||
|
"red": "#f38ba8",
|
||||||
|
"peach": "#fab387",
|
||||||
|
"lavender": "#b4befe",
|
||||||
|
"overlay0": "#6c7086",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_silo_base_url() -> str:
|
||||||
|
"""Return the Silo web UI root URL (without /api)."""
|
||||||
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||||
|
url = param.GetString("ApiUrl", "")
|
||||||
|
if not url:
|
||||||
|
url = os.environ.get("SILO_API_URL", "http://localhost:8080/api")
|
||||||
|
url = url.rstrip("/")
|
||||||
|
if url.endswith("/api"):
|
||||||
|
url = url[:-4]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _get_recent_files() -> list:
|
||||||
|
"""Read recent files from FreeCAD preferences."""
|
||||||
|
group = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/RecentFiles")
|
||||||
|
count = group.GetInt("RecentFiles", 0)
|
||||||
|
files = []
|
||||||
|
for i in range(count):
|
||||||
|
path = group.GetString(f"MRU{i}", "")
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
p = Path(path)
|
||||||
|
mtime = datetime.fromtimestamp(p.stat().st_mtime)
|
||||||
|
files.append({"path": str(p), "name": p.name, "modified": mtime})
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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 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.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):
|
||||||
|
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, 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("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(70)
|
||||||
|
self._retry_btn.setCursor(QtCore.Qt.PointingHandCursor)
|
||||||
|
self._retry_btn.clicked.connect(self._initial_load)
|
||||||
|
self._retry_btn.hide()
|
||||||
|
banner_layout.addWidget(self._retry_btn)
|
||||||
|
|
||||||
|
root.addWidget(banner)
|
||||||
|
root.addSpacing(20)
|
||||||
|
|
||||||
|
# --- 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)
|
||||||
|
|
||||||
|
self._file_list = QtWidgets.QListWidget()
|
||||||
|
self._file_list.setProperty("class", "SiloList")
|
||||||
|
self._file_list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||||
|
self._file_list.itemDoubleClicked.connect(self._on_file_clicked)
|
||||||
|
right_layout.addWidget(self._file_list, 1)
|
||||||
|
|
||||||
|
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()
|
||||||
|
footer.addStretch()
|
||||||
|
self._startup_cb = QtWidgets.QCheckBox("Don't show this page on startup")
|
||||||
|
start_prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Start")
|
||||||
|
show = start_prefs.GetBool("ShowOnStartup", True)
|
||||||
|
self._startup_cb.setChecked(not show)
|
||||||
|
self._startup_cb.toggled.connect(self._on_startup_toggled)
|
||||||
|
footer.addWidget(self._startup_cb)
|
||||||
|
root.addLayout(footer)
|
||||||
|
|
||||||
|
# -- data loading -------------------------------------------------------
|
||||||
|
|
||||||
|
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 _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._retry_btn.hide()
|
||||||
|
self._browser_btn.show()
|
||||||
|
else:
|
||||||
|
self._status_icon.setStyleSheet(
|
||||||
|
f"background-color: {_MOCHA['red']}; border-radius: 6px;"
|
||||||
|
)
|
||||||
|
self._retry_btn.show()
|
||||||
|
self._browser_btn.hide()
|
||||||
|
self._status_label.setText(message)
|
||||||
|
|
||||||
|
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.setFlags(QtCore.Qt.NoItemFlags)
|
||||||
|
self._file_list.addItem(item)
|
||||||
|
return
|
||||||
|
for f in files:
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
FreeCADGui.open(path)
|
||||||
|
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 ------------------------------------------------------------
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
self._stop_sse()
|
||||||
|
self._poll_timer.stop()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Command override
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SiloStartCommand:
|
||||||
|
"""Replacement for the C++ Start_Start command."""
|
||||||
|
|
||||||
|
def Activated(self):
|
||||||
|
mw = FreeCADGui.getMainWindow()
|
||||||
|
mdi = mw.findChild(QtWidgets.QMdiArea)
|
||||||
|
if not mdi:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reuse existing view if open
|
||||||
|
for sw in mdi.subWindowList():
|
||||||
|
if sw.widget() and sw.widget().objectName() == "SiloStartView":
|
||||||
|
mdi.setActiveSubWindow(sw)
|
||||||
|
sw.show()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create new view as MDI subwindow
|
||||||
|
view = SiloStartView()
|
||||||
|
sw = mdi.addSubWindow(view)
|
||||||
|
sw.setWindowTitle("Start")
|
||||||
|
sw.setWindowIcon(QtGui.QIcon(":/icons/StartCommandIcon.svg"))
|
||||||
|
sw.show()
|
||||||
|
mdi.setActiveSubWindow(sw)
|
||||||
|
|
||||||
|
def GetResources(self):
|
||||||
|
return {
|
||||||
|
"MenuText": "&Start Page",
|
||||||
|
"ToolTip": "Displays the start page",
|
||||||
|
"Pixmap": "StartCommandIcon",
|
||||||
|
}
|
||||||
|
|
||||||
|
def IsActive(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
"""Override the Start_Start command with the Silo start page.
|
||||||
|
|
||||||
|
Call this from InitGui.py at module level so the override is in
|
||||||
|
place before the C++ StartLauncher fires (100ms after GUI init).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
FreeCADGui.addCommand("Start_Start", _SiloStartCommand())
|
||||||
|
FreeCAD.Console.PrintMessage("Silo Start: registered start page override\n")
|
||||||
|
except Exception as e:
|
||||||
|
FreeCAD.Console.PrintWarning(f"Silo Start: failed to register override: {e}\n")
|
||||||
Reference in New Issue
Block a user