diff --git a/HolePattern.FCMacro b/HolePattern.FCMacro deleted file mode 100644 index ed4aa8e..0000000 --- a/HolePattern.FCMacro +++ /dev/null @@ -1,68 +0,0 @@ -import FreeCAD as App -import Sketcher -import Part - -def generate_hole_pattern(): - """Generate parametric hole grid from spreadsheet values.""" - - doc = App.ActiveDocument - if not doc: - App.Console.PrintError("No active document\n") - return - - # Create/get spreadsheet - sheet = doc.getObject('Spreadsheet') - if not sheet: - sheet = doc.addObject('Spreadsheet::Sheet', 'Spreadsheet') - sheet.set('A1', 'hole_spacing'); sheet.set('B1', '10 mm'); sheet.setAlias('B1', 'hole_spacing') - sheet.set('A2', 'hole_radius'); sheet.set('B2', '2.5 mm'); sheet.setAlias('B2', 'hole_radius') - sheet.set('A3', 'grid_offset_x'); sheet.set('B3', '10 mm'); sheet.setAlias('B3', 'grid_offset_x') - sheet.set('A4', 'grid_offset_y'); sheet.set('B4', '10 mm'); sheet.setAlias('B4', 'grid_offset_y') - sheet.set('A5', 'grid_cols'); sheet.set('B5', '5'); sheet.setAlias('B5', 'grid_cols') - sheet.set('A6', 'grid_rows'); sheet.set('B6', '5'); sheet.setAlias('B6', 'grid_rows') - doc.recompute() - App.Console.PrintMessage("Created Spreadsheet with default parameters\n") - - # Read grid size - cols = int(sheet.grid_cols) - rows = int(sheet.grid_rows) - - # Get/create sketch - sketch = doc.getObject('HolePatternSketch') - if not sketch: - body = doc.getObject('Body') - if body: - sketch = body.newObject('Sketcher::SketchObject', 'HolePatternSketch') - sketch.AttachmentSupport = [(body.Origin.XY_Plane, '')] - sketch.MapMode = 'FlatFace' - else: - sketch = doc.addObject('Sketcher::SketchObject', 'HolePatternSketch') - App.Console.PrintMessage("Created HolePatternSketch\n") - - # Clear existing geometry - for i in range(sketch.GeometryCount - 1, -1, -1): - sketch.delGeometry(i) - - # Generate pattern - for i in range(cols): - for j in range(rows): - circle_idx = sketch.addGeometry( - Part.Circle(App.Vector(0, 0, 0), App.Vector(0, 0, 1), 1), - False - ) - - cx = sketch.addConstraint(Sketcher.Constraint('DistanceX', -1, 1, circle_idx, 3, 10)) - sketch.setExpression(f'Constraints[{cx}]', f'Spreadsheet.grid_offset_x + {i} * Spreadsheet.hole_spacing') - - cy = sketch.addConstraint(Sketcher.Constraint('DistanceY', -1, 1, circle_idx, 3, 10)) - sketch.setExpression(f'Constraints[{cy}]', f'Spreadsheet.grid_offset_y + {j} * Spreadsheet.hole_spacing') - - r = sketch.addConstraint(Sketcher.Constraint('Radius', circle_idx, 1)) - sketch.setExpression(f'Constraints[{r}]', 'Spreadsheet.hole_radius') - - doc.recompute() - App.Console.PrintMessage(f"Generated {cols}x{rows} hole pattern ({cols*rows} holes)\n") - -# Run when macro is executed -if __name__ == '__main__': - generate_hole_pattern() diff --git a/pkg/freecad/Init.py b/pkg/freecad/Init.py deleted file mode 100644 index c5dd7c9..0000000 --- a/pkg/freecad/Init.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Silo FreeCAD Workbench - Console initialization. - -This file is loaded when FreeCAD starts (even in console mode). -The GUI-specific initialization is in InitGui.py. -""" - -# No console-only initialization needed for Silo workbench -# All functionality requires the GUI diff --git a/pkg/freecad/InitGui.py b/pkg/freecad/InitGui.py deleted file mode 100644 index 0a6d1b9..0000000 --- a/pkg/freecad/InitGui.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Kindred Silo Workbench - Item database integration for Kindred Create.""" - -import os - -import FreeCAD -import FreeCADGui - -FreeCAD.Console.PrintMessage("Kindred Silo InitGui.py loading...\n") - - -class SiloWorkbench(FreeCADGui.Workbench): - """Kindred Silo workbench for item database integration.""" - - MenuText = "Kindred Silo" - ToolTip = "Item database and part management for Kindred Create" - Icon = "" - - def __init__(self): - # Resolve icon relative to this file so it works regardless of install location - icon_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "resources", "icons", "silo.svg" - ) - if os.path.exists(icon_path): - self.__class__.Icon = icon_path - - def Initialize(self): - """Called when workbench is first activated.""" - import silo_commands - - # Register Silo as a file origin in the unified origin system - try: - import silo_origin - - silo_origin.register_silo_origin() - except Exception as e: - FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n") - - self.toolbar_commands = [ - "Silo_ToggleMode", - "Separator", - "Silo_Open", - "Silo_New", - "Silo_Save", - "Silo_Commit", - "Silo_Pull", - "Silo_Push", - "Silo_Info", - "Silo_BOM", - "Silo_Settings", - "Silo_Auth", - ] - - self.appendToolbar("Silo", self.toolbar_commands) - self.appendMenu("Silo", self.toolbar_commands) - - def Activated(self): - """Called when workbench is activated.""" - FreeCAD.Console.PrintMessage("Kindred Silo workbench activated\n") - self._show_shortcut_recommendations() - - def Deactivated(self): - pass - - def GetClassName(self): - return "Gui::PythonWorkbench" - - def _show_shortcut_recommendations(self): - """Show keyboard shortcut recommendations dialog on first activation.""" - try: - param_group = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/KindredSilo") - if param_group.GetBool("ShortcutsShown", False): - return - param_group.SetBool("ShortcutsShown", True) - - from PySide import QtGui - - msg = """

Welcome to Kindred Silo!

-

For the best experience, set up these keyboard shortcuts:

- - - - - -
Ctrl+O - Silo_Open (Search & Open)
Ctrl+N - Silo_New (Register new item)
Ctrl+S - Silo_Save (Save & upload)
Ctrl+Shift+S - Silo_Commit (Save with comment)
-

To set shortcuts: Tools > Customize > Keyboard

-

This message appears once.

""" - - dialog = QtGui.QMessageBox() - dialog.setWindowTitle("Silo Keyboard Shortcuts") - dialog.setTextFormat(QtGui.Qt.RichText) - dialog.setText(msg) - dialog.setIcon(QtGui.QMessageBox.Information) - dialog.addButton("Set Up Now", QtGui.QMessageBox.AcceptRole) - dialog.addButton("Later", QtGui.QMessageBox.RejectRole) - if dialog.exec_() == 0: - FreeCADGui.runCommand("Std_DlgCustomize", 0) - except Exception as e: - FreeCAD.Console.PrintWarning("Silo shortcuts dialog: " + str(e) + "\n") - - -FreeCADGui.addWorkbench(SiloWorkbench()) -FreeCAD.Console.PrintMessage("Silo workbench registered\n") diff --git a/pkg/freecad/package.xml b/pkg/freecad/package.xml deleted file mode 100644 index 73e54a9..0000000 --- a/pkg/freecad/package.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - Kindred Silo - Item database and part management workbench for Kindred Create - 0.1.0 - Kindred Systems - MIT - https://github.com/kindredsystems/silo - - - SiloWorkbench - ./ - - - diff --git a/pkg/freecad/resources/icons/silo-auth.svg b/pkg/freecad/resources/icons/silo-auth.svg deleted file mode 100644 index d05c992..0000000 --- a/pkg/freecad/resources/icons/silo-auth.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/pkg/freecad/resources/icons/silo-bom.svg b/pkg/freecad/resources/icons/silo-bom.svg deleted file mode 100644 index 1ea69dc..0000000 --- a/pkg/freecad/resources/icons/silo-bom.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/pkg/freecad/resources/icons/silo-commit.svg b/pkg/freecad/resources/icons/silo-commit.svg deleted file mode 100644 index f49b77c..0000000 --- a/pkg/freecad/resources/icons/silo-commit.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/pkg/freecad/resources/icons/silo-info.svg b/pkg/freecad/resources/icons/silo-info.svg deleted file mode 100644 index 2a48196..0000000 --- a/pkg/freecad/resources/icons/silo-info.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/pkg/freecad/resources/icons/silo-new.svg b/pkg/freecad/resources/icons/silo-new.svg deleted file mode 100644 index 5bf6836..0000000 --- a/pkg/freecad/resources/icons/silo-new.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/pkg/freecad/resources/icons/silo-open.svg b/pkg/freecad/resources/icons/silo-open.svg deleted file mode 100644 index ef555e6..0000000 --- a/pkg/freecad/resources/icons/silo-open.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/pkg/freecad/resources/icons/silo-pull.svg b/pkg/freecad/resources/icons/silo-pull.svg deleted file mode 100644 index 8c25cec..0000000 --- a/pkg/freecad/resources/icons/silo-pull.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/pkg/freecad/resources/icons/silo-push.svg b/pkg/freecad/resources/icons/silo-push.svg deleted file mode 100644 index 585fdd7..0000000 --- a/pkg/freecad/resources/icons/silo-push.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/pkg/freecad/resources/icons/silo-save.svg b/pkg/freecad/resources/icons/silo-save.svg deleted file mode 100644 index f20eb88..0000000 --- a/pkg/freecad/resources/icons/silo-save.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/pkg/freecad/resources/icons/silo.svg b/pkg/freecad/resources/icons/silo.svg deleted file mode 100644 index 29dd81d..0000000 --- a/pkg/freecad/resources/icons/silo.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/pkg/freecad/silo_commands.py b/pkg/freecad/silo_commands.py deleted file mode 100644 index f46c73b..0000000 --- a/pkg/freecad/silo_commands.py +++ /dev/null @@ -1,3592 +0,0 @@ -"""Silo FreeCAD commands - Streamlined workflow for CAD file management.""" - -import json -import os -import re -import ssl -import urllib.error -import urllib.parse -import urllib.request -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import FreeCAD -import FreeCADGui -from PySide import QtCore - -# Preference group for Kindred Silo settings -_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo" - -# Configuration - preferences take priority over env vars -SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects")) - - -def _get_api_url() -> str: - """Get Silo API URL from preferences, falling back to env var then default.""" - 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("/") - # Auto-append /api when the user provides just a bare origin with no path, - # e.g. "https://silo.kindred.internal" -> "https://silo.kindred.internal/api" - # but leave URLs that already have a path alone, - # e.g. "http://localhost:8080/api" stays as-is. - if url: - parsed = urllib.parse.urlparse(url) - if not parsed.path or parsed.path == "/": - url = url + "/api" - return url - - -def _get_api_token() -> str: - """Get Silo API token from preferences, falling back to env var.""" - param = FreeCAD.ParamGet(_PREF_GROUP) - token = param.GetString("ApiToken", "") - if not token: - token = os.environ.get("SILO_API_TOKEN", "") - return token - - -def _get_ssl_verify() -> bool: - """Get SSL verification setting from preferences.""" - param = FreeCAD.ParamGet(_PREF_GROUP) - return param.GetBool("SslVerify", True) - - -def _get_ssl_context() -> ssl.SSLContext: - """Build an SSL context based on the current SSL verification preference.""" - if _get_ssl_verify(): - ctx = ssl.create_default_context() - # Load custom CA certificate if configured (for internal CAs) - param = FreeCAD.ParamGet(_PREF_GROUP) - custom_cert = param.GetString("SslCertPath", "") - if custom_cert and os.path.isfile(custom_cert): - try: - ctx.load_verify_locations(custom_cert) - except Exception as e: - FreeCAD.Console.PrintWarning( - f"Silo: Failed to load custom cert {custom_cert}: {e}\n" - ) - # The bundled Python may not find the system CA store automatically - # (its compiled-in path points to the build environment). Load the - # system CA bundle explicitly so internal CAs (e.g. FreeIPA) are trusted. - for ca_path in ( - "/etc/ssl/certs/ca-certificates.crt", # Debian / Ubuntu - "/etc/pki/tls/certs/ca-bundle.crt", # RHEL / CentOS - ): - if os.path.isfile(ca_path): - try: - ctx.load_verify_locations(ca_path) - except Exception: - pass - break - return ctx - else: - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - return ctx - - -def _get_auth_token() -> str: - """Get the active API token for authenticating requests. - - Priority: ApiToken preference > SILO_API_TOKEN env var. - """ - return _get_api_token() - - -def _get_auth_username() -> str: - """Get stored authenticated username from preferences.""" - param = FreeCAD.ParamGet(_PREF_GROUP) - return param.GetString("AuthUsername", "") - - -def _get_auth_role() -> str: - """Get stored authenticated user role from preferences.""" - param = FreeCAD.ParamGet(_PREF_GROUP) - return param.GetString("AuthRole", "") - - -def _get_auth_source() -> str: - """Get stored authentication source from preferences.""" - param = FreeCAD.ParamGet(_PREF_GROUP) - return param.GetString("AuthSource", "") - - -def _get_auth_headers() -> Dict[str, str]: - """Return Authorization header dict if a token is configured, else empty.""" - token = _get_auth_token() - if token: - return {"Authorization": f"Bearer {token}"} - return {} - - -def _save_auth_info(username: str, role: str = "", source: str = "", token: str = ""): - """Store authentication info in preferences.""" - param = FreeCAD.ParamGet(_PREF_GROUP) - param.SetString("AuthUsername", username) - param.SetString("AuthRole", role) - param.SetString("AuthSource", source) - if token: - param.SetString("ApiToken", token) - - -def _clear_auth(): - """Clear stored authentication credentials from preferences.""" - param = FreeCAD.ParamGet(_PREF_GROUP) - param.SetString("ApiToken", "") - param.SetString("AuthUsername", "") - param.SetString("AuthRole", "") - param.SetString("AuthSource", "") - - -# Category name mapping for folder structure -# Format: CCC -> "descriptive_name" -CATEGORY_NAMES = { - # Fasteners - "F01": "screws_bolts", - "F02": "threaded_rods", - "F03": "eyebolts", - "F04": "u_bolts", - "F05": "nuts", - "F06": "washers", - "F07": "shims", - "F08": "inserts", - "F09": "spacers", - "F10": "pins", - "F11": "anchors", - "F12": "nails", - "F13": "rivets", - "F14": "staples", - "F15": "key_stock", - "F16": "retaining_rings", - "F17": "cable_ties", - "F18": "hook_loop", - # Fluid Fittings - "C01": "full_couplings", - "C02": "half_couplings", - "C03": "reducers", - "C04": "elbows", - "C05": "tees", - "C06": "crosses", - "C07": "unions", - "C08": "adapters", - "C09": "plugs_caps", - "C10": "nipples", - "C11": "flanges", - "C12": "valves", - "C13": "quick_disconnects", - "C14": "hose_barbs", - "C15": "compression_fittings", - "C16": "tubing", - "C17": "hoses", - # Motion Components - "R01": "ball_bearings", - "R02": "roller_bearings", - "R03": "sleeve_bearings", - "R04": "thrust_bearings", - "R05": "linear_bearings", - "R06": "spur_gears", - "R07": "helical_gears", - "R08": "bevel_gears", - "R09": "worm_gears", - "R10": "rack_pinion", - "R11": "sprockets", - "R12": "timing_pulleys", - "R13": "v_belt_pulleys", - "R14": "idler_pulleys", - "R15": "wheels", - "R16": "casters", - "R17": "shaft_couplings", - "R18": "clutches", - "R19": "brakes", - "R20": "lead_screws", - "R21": "ball_screws", - "R22": "linear_rails", - "R23": "linear_actuators", - "R24": "brushed_dc_motor", - "R25": "brushless_dc_motor", - "R26": "stepper_motor", - "R27": "servo_motor", - "R28": "ac_induction_motor", - "R29": "gear_motor", - "R30": "motor_driver", - "R31": "motor_controller", - "R32": "encoder", - "R33": "pneumatic_cylinder", - "R34": "pneumatic_actuator", - "R35": "pneumatic_valve", - "R36": "pneumatic_regulator", - "R37": "pneumatic_frl_unit", - "R38": "air_compressor", - "R39": "vacuum_pump", - "R40": "hydraulic_cylinder", - "R41": "hydraulic_pump", - "R42": "hydraulic_motor", - "R43": "hydraulic_valve", - "R44": "hydraulic_accumulator", - # Structural Materials - "S01": "square_tube", - "S02": "round_tube", - "S03": "rectangular_tube", - "S04": "i_beam", - "S05": "t_slot_extrusion", - "S06": "angle", - "S07": "channel", - "S08": "flat_bar", - "S09": "round_bar", - "S10": "square_bar", - "S11": "hex_bar", - "S12": "sheet_metal", - "S13": "plate", - "S14": "expanded_metal", - "S15": "perforated_sheet", - "S16": "wire_mesh", - "S17": "grating", - # Electrical Components - "E01": "wire", - "E02": "cable", - "E03": "connectors", - "E04": "terminals", - "E05": "circuit_breakers", - "E06": "fuses", - "E07": "relays", - "E08": "contactors", - "E09": "switches", - "E10": "buttons", - "E11": "indicators", - "E12": "resistors", - "E13": "capacitors", - "E14": "inductors", - "E15": "transformers", - "E16": "diodes", - "E17": "transistors", - "E18": "ics", - "E19": "microcontrollers", - "E20": "sensors", - "E21": "displays", - "E22": "power_supplies", - "E23": "batteries", - "E24": "pcb", - "E25": "enclosures", - "E26": "heat_sinks", - "E27": "fans", - # Mechanical Components - "M01": "compression_springs", - "M02": "extension_springs", - "M03": "torsion_springs", - "M04": "gas_springs", - "M05": "dampers", - "M06": "shock_absorbers", - "M07": "vibration_mounts", - "M08": "hinges", - "M09": "latches", - "M10": "handles", - "M11": "knobs", - "M12": "levers", - "M13": "linkages", - "M14": "cams", - "M15": "bellows", - "M16": "seals", - "M17": "o_rings", - "M18": "gaskets", - # Tooling and Fixtures - "T01": "jigs", - "T02": "fixtures", - "T03": "molds", - "T04": "dies", - "T05": "gauges", - "T06": "templates", - "T07": "work_holding", - "T08": "test_fixtures", - # Assemblies - "A01": "mechanical_assembly", - "A02": "electrical_assembly", - "A03": "electromechanical_assembly", - "A04": "subassembly", - "A05": "cable_assembly", - "A06": "pneumatic_assembly", - "A07": "hydraulic_assembly", - # Purchased/Off-the-Shelf - "P01": "purchased_mechanical", - "P02": "purchased_electrical", - "P03": "purchased_assembly", - "P04": "raw_material", - "P05": "consumables", - # Custom Fabricated Parts - "X01": "machined_part", - "X02": "sheet_metal_part", - "X03": "3d_printed_part", - "X04": "cast_part", - "X05": "molded_part", - "X06": "welded_fabrication", - "X07": "laser_cut_part", - "X08": "waterjet_cut_part", -} - - -# Icon directory - resolve relative to this file so it works regardless of install location -_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons") - - -def _icon(name): - """Get icon path by name.""" - if _ICON_DIR: - path = os.path.join(_ICON_DIR, f"silo-{name}.svg") - if os.path.exists(path): - return path - return "" - - -def get_projects_dir() -> Path: - """Get the projects directory.""" - projects_dir = Path(SILO_PROJECTS_DIR) - projects_dir.mkdir(parents=True, exist_ok=True) - return projects_dir - - -class SiloClient: - """HTTP client for Silo API.""" - - def __init__(self, base_url: str = None): - self._explicit_url = base_url - - @property - def base_url(self) -> str: - if self._explicit_url: - return self._explicit_url.rstrip("/") - return _get_api_url().rstrip("/") - - def _request(self, method: str, path: str, data: Optional[Dict] = None) -> Dict[str, Any]: - """Make HTTP request to Silo API.""" - url = f"{self.base_url}{path}" - headers = {"Content-Type": "application/json"} - headers.update(_get_auth_headers()) - body = json.dumps(data).encode() if data else None - req = urllib.request.Request(url, data=body, headers=headers, method=method) - - try: - with urllib.request.urlopen(req, context=_get_ssl_context()) as resp: - return json.loads(resp.read().decode()) - except urllib.error.HTTPError as e: - if e.code == 401: - _clear_auth() - error_body = e.read().decode() - raise RuntimeError(f"API error {e.code}: {error_body}") - except urllib.error.URLError as e: - raise RuntimeError(f"Connection error: {e.reason}") - - def _download_file( - self, - part_number: str, - revision: int, - dest_path: str, - progress_callback=None, - ) -> bool: - """Download a file from MinIO storage. - - Args: - progress_callback: Optional callable(bytes_downloaded, total_bytes). - total_bytes is -1 if the server did not send Content-Length. - """ - url = f"{self.base_url}/items/{part_number}/file/{revision}" - req = urllib.request.Request(url, headers=_get_auth_headers(), method="GET") - - try: - with urllib.request.urlopen(req, context=_get_ssl_context()) as resp: - total = int(resp.headers.get("Content-Length", -1)) - downloaded = 0 - with open(dest_path, "wb") as f: - while True: - chunk = resp.read(8192) - if not chunk: - break - f.write(chunk) - downloaded += len(chunk) - if progress_callback is not None: - progress_callback(downloaded, total) - return True - except urllib.error.HTTPError as e: - if e.code == 404: - return False - raise RuntimeError(f"Download error {e.code}: {e.read().decode()}") - except urllib.error.URLError as e: - raise RuntimeError(f"Connection error: {e.reason}") - - def _upload_file( - self, part_number: str, file_path: str, properties: Dict, comment: str = "" - ) -> Dict[str, Any]: - """Upload a file and create a new revision.""" - import mimetypes - - url = f"{self.base_url}/items/{part_number}/file" - - with open(file_path, "rb") as f: - file_data = f.read() - - boundary = "----SiloUploadBoundary" + str(hash(file_path))[-8:] - body_parts = [] - - filename = os.path.basename(file_path) - content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" - body_parts.append( - f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n' - ) - body_parts.append(file_data) - body_parts.append(b"\r\n") - - if comment: - body_parts.append( - f'--{boundary}\r\nContent-Disposition: form-data; name="comment"\r\n\r\n{comment}\r\n' - ) - - if properties: - # Ensure properties is valid JSON - handle special float values - props_json = json.dumps(properties, allow_nan=False, default=str) - body_parts.append( - f'--{boundary}\r\nContent-Disposition: form-data; name="properties"\r\n\r\n{props_json}\r\n' - ) - - body_parts.append(f"--{boundary}--\r\n") - - body = b"" - for part in body_parts: - body += part.encode("utf-8") if isinstance(part, str) else part - - headers = { - "Content-Type": f"multipart/form-data; boundary={boundary}", - "Content-Length": str(len(body)), - } - headers.update(_get_auth_headers()) - req = urllib.request.Request(url, data=body, headers=headers, method="POST") - - try: - with urllib.request.urlopen(req, context=_get_ssl_context()) as resp: - return json.loads(resp.read().decode()) - except urllib.error.HTTPError as e: - raise RuntimeError(f"Upload error {e.code}: {e.read().decode()}") - except urllib.error.URLError as e: - raise RuntimeError(f"Connection error: {e.reason}") - - def get_item(self, part_number: str) -> Dict[str, Any]: - return self._request("GET", f"/items/{part_number}") - - def list_items(self, search: str = "", item_type: str = "", project: str = "") -> list: - params = ["limit=100"] - if search: - params.append(f"search={urllib.parse.quote(search)}") - if item_type: - params.append(f"type={item_type}") - if project: - params.append(f"project={project}") - return self._request("GET", "/items?" + "&".join(params)) - - def create_item( - self, - schema: str, - category: str, - description: str = "", - projects: List[str] = None, - ) -> Dict[str, Any]: - """Create a new item with optional project tags.""" - data = { - "schema": schema, - "category": category, - "description": description, - } - if projects: - data["projects"] = projects - return self._request("POST", "/items", data) - - def update_item( - self, part_number: str, description: str = None, item_type: str = None - ) -> Dict[str, Any]: - data = {} - if description is not None: - data["description"] = description - if item_type is not None: - data["item_type"] = item_type - return self._request("PUT", f"/items/{part_number}", data) - - def get_revisions(self, part_number: str) -> list: - return self._request("GET", f"/items/{part_number}/revisions") - - def get_schema(self, name: str = "kindred-rd") -> Dict[str, Any]: - return self._request("GET", f"/schemas/{name}") - - def get_projects(self) -> list: - """Get list of all projects.""" - return self._request("GET", "/projects") - - def get_item_projects(self, part_number: str) -> list: - """Get projects associated with an item.""" - return self._request("GET", f"/items/{part_number}/projects") - - def add_item_projects(self, part_number: str, project_codes: List[str]) -> Dict[str, Any]: - """Add project tags to an item.""" - return self._request("POST", f"/items/{part_number}/projects", {"projects": project_codes}) - - def has_file(self, part_number: str) -> Tuple[bool, Optional[int]]: - """Check if item has files in MinIO.""" - try: - revisions = self.get_revisions(part_number) - for rev in revisions: - if rev.get("file_key"): - return True, rev["revision_number"] - return False, None - except Exception: - return False, None - - def latest_file_revision(self, part_number: str) -> Optional[Dict]: - """Return the most recent revision that has a file attached, or None.""" - try: - revisions = self.get_revisions(part_number) - for rev in revisions: # revisions come newest-first from the API - if rev.get("file_key"): - return rev - return None - except Exception: - return None - - def compare_revisions(self, part_number: str, from_rev: int, to_rev: int) -> Dict[str, Any]: - """Compare two revisions and return differences.""" - return self._request( - "GET", - f"/items/{part_number}/revisions/compare?from={from_rev}&to={to_rev}", - ) - - def rollback_revision( - self, part_number: str, revision: int, comment: str = "" - ) -> Dict[str, Any]: - """Create a new revision by rolling back to a previous one.""" - data = {} - if comment: - data["comment"] = comment - return self._request("POST", f"/items/{part_number}/revisions/{revision}/rollback", data) - - def update_revision( - self, part_number: str, revision: int, status: str = None, labels: list = None - ) -> Dict[str, Any]: - """Update revision status and/or labels.""" - data = {} - if status: - data["status"] = status - if labels is not None: - data["labels"] = labels - return self._request("PATCH", f"/items/{part_number}/revisions/{revision}", data) - - # BOM / Relationship methods - - def get_bom(self, part_number: str) -> list: - """Get single-level BOM for an item.""" - return self._request("GET", f"/items/{part_number}/bom") - - def get_bom_expanded(self, part_number: str, depth: int = 10) -> list: - """Get multi-level BOM for an item.""" - return self._request("GET", f"/items/{part_number}/bom/expanded?depth={depth}") - - def get_bom_where_used(self, part_number: str) -> list: - """Get assemblies that use this item.""" - return self._request("GET", f"/items/{part_number}/bom/where-used") - - def add_bom_entry( - self, - parent_pn: str, - child_pn: str, - quantity: float = None, - unit: str = None, - rel_type: str = "component", - ref_des: list = None, - ) -> Dict[str, Any]: - """Add a child item to a parent's BOM.""" - data: Dict[str, Any] = { - "child_part_number": child_pn, - "rel_type": rel_type, - } - if quantity is not None: - data["quantity"] = quantity - if unit: - data["unit"] = unit - if ref_des: - data["reference_designators"] = ref_des - return self._request("POST", f"/items/{parent_pn}/bom", data) - - def update_bom_entry( - self, - parent_pn: str, - child_pn: str, - quantity: float = None, - unit: str = None, - rel_type: str = None, - ref_des: list = None, - ) -> Dict[str, Any]: - """Update a BOM entry.""" - data: Dict[str, Any] = {} - if quantity is not None: - data["quantity"] = quantity - if unit is not None: - data["unit"] = unit - if rel_type is not None: - data["rel_type"] = rel_type - if ref_des is not None: - data["reference_designators"] = ref_des - return self._request("PUT", f"/items/{parent_pn}/bom/{child_pn}", data) - - def delete_bom_entry(self, parent_pn: str, child_pn: str) -> None: - """Remove a child from a parent's BOM.""" - url = f"{self.base_url}/items/{parent_pn}/bom/{child_pn}" - headers = {"Content-Type": "application/json"} - headers.update(_get_auth_headers()) - req = urllib.request.Request( - url, - headers=headers, - method="DELETE", - ) - try: - urllib.request.urlopen(req, context=_get_ssl_context()) - except urllib.error.HTTPError as e: - error_body = e.read().decode() - raise RuntimeError(f"API error {e.code}: {error_body}") - except urllib.error.URLError as e: - raise RuntimeError(f"Connection error: {e.reason}") - - # -- Authentication methods --------------------------------------------- - - def login(self, username: str, password: str) -> Dict[str, Any]: - """Authenticate with credentials and obtain an API token. - - Performs a session-based login (POST /login), then uses the - session to create a persistent API token via POST /api/auth/tokens. - The API token is stored in preferences for future requests. - """ - import http.cookiejar - - # Build a cookie-aware opener for the session flow - base = self.base_url - # Strip /api suffix to get the server root for /login - origin = base.rsplit("/api", 1)[0] if base.endswith("/api") else base - ctx = _get_ssl_context() - cookie_jar = http.cookiejar.CookieJar() - opener = urllib.request.build_opener( - urllib.request.HTTPCookieProcessor(cookie_jar), - urllib.request.HTTPSHandler(context=ctx), - ) - - # Step 1: POST form-encoded credentials to /login - login_url = f"{origin}/login" - form_data = urllib.parse.urlencode({"username": username, "password": password}).encode() - req = urllib.request.Request( - login_url, - data=form_data, - method="POST", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - try: - opener.open(req) - except urllib.error.HTTPError as e: - if e.code in (302, 303): - pass # Redirect after login is expected - else: - raise RuntimeError( - f"Login failed (HTTP {e.code}): invalid credentials or server error" - ) - except urllib.error.URLError as e: - raise RuntimeError(f"Connection error: {e.reason}") - - # Step 2: Verify session by calling /api/auth/me - me_url = f"{origin}/api/auth/me" - me_req = urllib.request.Request(me_url, method="GET") - try: - with opener.open(me_req) as resp: - user_info = json.loads(resp.read().decode()) - except urllib.error.HTTPError as e: - if e.code == 401: - raise RuntimeError("Login failed: invalid username or password") - raise RuntimeError(f"Login verification failed (HTTP {e.code})") - except urllib.error.URLError as e: - raise RuntimeError(f"Connection error: {e.reason}") - - # Step 3: Create a persistent API token for FreeCAD - token_url = f"{origin}/api/auth/tokens" - import socket - - hostname = socket.gethostname() - token_body = json.dumps({"name": f"FreeCAD ({hostname})", "expires_in_days": 90}).encode() - token_req = urllib.request.Request( - token_url, - data=token_body, - method="POST", - headers={"Content-Type": "application/json"}, - ) - try: - with opener.open(token_req) as resp: - token_result = json.loads(resp.read().decode()) - except urllib.error.HTTPError as e: - raise RuntimeError(f"Failed to create API token (HTTP {e.code})") - except urllib.error.URLError as e: - raise RuntimeError(f"Connection error: {e.reason}") - - raw_token = token_result.get("token", "") - if not raw_token: - raise RuntimeError("Server did not return an API token") - - # Store token and user info - _save_auth_info( - username=user_info.get("username", username), - role=user_info.get("role", ""), - source=user_info.get("auth_source", ""), - token=raw_token, - ) - - return { - "username": user_info.get("username", username), - "role": user_info.get("role", ""), - "auth_source": user_info.get("auth_source", ""), - "token_name": token_result.get("name", ""), - "token_prefix": token_result.get("token_prefix", ""), - } - - def logout(self): - """Clear stored API token and authentication info.""" - _clear_auth() - - def is_authenticated(self) -> bool: - """Return True if a valid API token is configured.""" - return bool(_get_auth_token()) - - def auth_username(self) -> str: - """Return the stored authenticated username.""" - return _get_auth_username() - - def auth_role(self) -> str: - """Return the stored user role.""" - return _get_auth_role() - - def auth_source(self) -> str: - """Return the stored authentication source (local, ldap, oidc).""" - return _get_auth_source() - - def get_current_user(self) -> Optional[Dict[str, Any]]: - """Fetch the current user info from the server. - - Returns user dict or None if not authenticated. - """ - try: - return self._request("GET", "/auth/me") - except RuntimeError: - return None - - def refresh_auth_info(self) -> bool: - """Refresh locally cached user info from the server. - - Returns True if authenticated, False otherwise. - """ - user = self.get_current_user() - if user and user.get("username"): - _save_auth_info( - username=user["username"], - role=user.get("role", ""), - source=user.get("auth_source", ""), - ) - return True - return False - - def list_tokens(self) -> List[Dict[str, Any]]: - """List API tokens for the current user.""" - return self._request("GET", "/auth/tokens") - - def create_token(self, name: str, expires_in_days: Optional[int] = None) -> Dict[str, Any]: - """Create a new API token. - - Returns dict with 'token' (raw, shown once), 'id', 'name', etc. - """ - data: Dict[str, Any] = {"name": name} - if expires_in_days is not None: - data["expires_in_days"] = expires_in_days - return self._request("POST", "/auth/tokens", data) - - def revoke_token(self, token_id: str) -> None: - """Revoke an API token by its ID.""" - url = f"{self.base_url}/auth/tokens/{token_id}" - headers = {"Content-Type": "application/json"} - headers.update(_get_auth_headers()) - req = urllib.request.Request(url, headers=headers, method="DELETE") - try: - urllib.request.urlopen(req, context=_get_ssl_context()) - except urllib.error.HTTPError as e: - error_body = e.read().decode() - raise RuntimeError(f"API error {e.code}: {error_body}") - except urllib.error.URLError as e: - raise RuntimeError(f"Connection error: {e.reason}") - - def check_connection(self) -> Tuple[bool, str]: - """Check connectivity to the Silo API. - - Returns (reachable, message). - """ - # Use origin /health (not /api/health) since health is at root - base = self.base_url - origin = base.rsplit("/api", 1)[0] if base.endswith("/api") else base - url = f"{origin}/health" - req = urllib.request.Request(url, method="GET") - try: - with urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5) as resp: - return True, f"OK ({resp.status})" - except urllib.error.HTTPError as e: - return True, f"Server error ({e.code})" - except urllib.error.URLError as e: - return False, str(e.reason) - except Exception as e: - return False, str(e) - - -_client = SiloClient() - - -# Utility functions - - -def sanitize_filename(name: str) -> str: - """Sanitize a string for use in filenames.""" - sanitized = re.sub(r'[<>:"/\\|?*]', "_", name) - sanitized = re.sub(r"[\s_]+", "_", sanitized) - sanitized = sanitized.strip("_ ") - return sanitized[:50] - - -def parse_part_number(part_number: str) -> Tuple[str, str]: - """Parse part number into (category, sequence). - - New format: CCC-NNNN (e.g., F01-0001) - Returns: (category_code, sequence) - """ - parts = part_number.split("-") - if len(parts) >= 2: - return parts[0], parts[1] - return part_number, "" - - -def get_category_folder_name(category_code: str) -> str: - """Get the folder name for a category (e.g., 'F01_screws_bolts').""" - name = CATEGORY_NAMES.get(category_code.upper(), "misc") - return f"{category_code}_{name}" - - -def get_cad_file_path(part_number: str, description: str = "") -> Path: - """Generate canonical file path for a CAD file. - - Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.FCStd - Example: ~/projects/cad/F01_screws_bolts/F01-0001_M3_Socket_Screw.FCStd - """ - category, _ = parse_part_number(part_number) - folder_name = get_category_folder_name(category) - - if description: - filename = f"{part_number}_{sanitize_filename(description)}.FCStd" - else: - filename = f"{part_number}.FCStd" - - return get_projects_dir() / "cad" / folder_name / filename - - -def find_file_by_part_number(part_number: str) -> Optional[Path]: - """Find existing CAD file for a part number.""" - category, _ = parse_part_number(part_number) - folder_name = get_category_folder_name(category) - cad_dir = get_projects_dir() / "cad" / folder_name - - if cad_dir.exists(): - matches = list(cad_dir.glob(f"{part_number}*.FCStd")) - if matches: - return matches[0] - - # Also search in base cad directory (for older files or different structures) - base_cad_dir = get_projects_dir() / "cad" - if base_cad_dir.exists(): - # Search all subdirectories - for subdir in base_cad_dir.iterdir(): - if subdir.is_dir(): - matches = list(subdir.glob(f"{part_number}*.FCStd")) - if matches: - return matches[0] - - return None - - -def search_local_files(search_term: str = "", category_filter: str = "") -> list: - """Search for CAD files in local cad directory.""" - results = [] - cad_dir = get_projects_dir() / "cad" - if not cad_dir.exists(): - return results - - search_lower = search_term.lower() - - for category_dir in cad_dir.iterdir(): - if not category_dir.is_dir(): - continue - - # Extract category code from folder name (e.g., "F01_screws_bolts" -> "F01") - folder_name = category_dir.name - category_code = folder_name.split("_")[0] if "_" in folder_name else folder_name - - if category_filter and category_code.upper() != category_filter.upper(): - continue - - for fcstd_file in category_dir.glob("*.FCStd"): - filename = fcstd_file.stem - parts = filename.split("_", 1) - part_number = parts[0] - description = parts[1].replace("_", " ") if len(parts) > 1 else "" - - if search_term: - searchable = f"{part_number} {description}".lower() - if search_lower not in searchable: - continue - - try: - from datetime import datetime - - mtime = fcstd_file.stat().st_mtime - modified = datetime.fromtimestamp(mtime).isoformat() - except Exception: - modified = None - - results.append( - { - "path": str(fcstd_file), - "part_number": part_number, - "description": description, - "category": category_code, - "modified": modified, - "source": "local", - } - ) - - results.sort(key=lambda x: x.get("modified") or "", reverse=True) - return results - - -def _safe_float(val): - """Convert float to JSON-safe value, handling NaN and Infinity.""" - import math - - if isinstance(val, float): - if math.isnan(val) or math.isinf(val): - return 0.0 - return val - - -def collect_document_properties(doc) -> Dict[str, Any]: - """Collect properties from all objects in a document.""" - result = { - "_document_name": doc.Name, - "_file_name": doc.FileName or None, - "objects": {}, - } - - for obj in doc.Objects: - if obj.TypeId.startswith("App::") and obj.TypeId not in ("App::Part",): - continue - - props = {"_object_type": obj.TypeId, "_label": obj.Label} - - if hasattr(obj, "Placement"): - p = obj.Placement - props["placement"] = { - "position": { - "x": _safe_float(p.Base.x), - "y": _safe_float(p.Base.y), - "z": _safe_float(p.Base.z), - }, - "rotation": { - "axis": { - "x": _safe_float(p.Rotation.Axis.x), - "y": _safe_float(p.Rotation.Axis.y), - "z": _safe_float(p.Rotation.Axis.z), - }, - "angle": _safe_float(p.Rotation.Angle), - }, - } - - if hasattr(obj, "Shape") and obj.Shape: - try: - bbox = obj.Shape.BoundBox - props["bounding_box"] = { - "x_length": _safe_float(bbox.XLength), - "y_length": _safe_float(bbox.YLength), - "z_length": _safe_float(bbox.ZLength), - } - if hasattr(obj.Shape, "Volume"): - props["volume"] = _safe_float(obj.Shape.Volume) - except Exception: - pass - - result["objects"][obj.Label] = props - - return result - - -def set_silo_properties(obj, props: Dict[str, Any]): - """Set Silo properties on FreeCAD object.""" - for name, value in props.items(): - if not hasattr(obj, name): - if isinstance(value, str): - obj.addProperty("App::PropertyString", name, "Silo", "") - elif isinstance(value, int): - obj.addProperty("App::PropertyInteger", name, "Silo", "") - setattr(obj, name, value) - - -def get_tracked_object(doc): - """Find the primary tracked object in a document.""" - for obj in doc.Objects: - if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber: - return obj - return None - - -class SiloSync: - """Handles synchronization between FreeCAD and Silo.""" - - def __init__(self, client: SiloClient = None): - self.client = client or _client - - def save_to_canonical_path(self, doc, force_rename: bool = False) -> Optional[Path]: - """Save document to canonical path.""" - obj = get_tracked_object(doc) - if not obj: - return None - - part_number = obj.SiloPartNumber - - try: - item = self.client.get_item(part_number) - description = item.get("description", "") - new_path = get_cad_file_path(part_number, description) - new_path.parent.mkdir(parents=True, exist_ok=True) - - existing_path = find_file_by_part_number(part_number) - current_path = Path(doc.FileName) if doc.FileName else None - - # Use save() if already at the correct path, saveAs() only if path changes - if current_path and current_path == new_path: - doc.save() - elif ( - existing_path - and existing_path != new_path - and (force_rename or current_path == existing_path) - ): - doc.saveAs(str(new_path)) - try: - existing_path.unlink() - except OSError: - pass - else: - doc.saveAs(str(new_path)) - - return new_path - except Exception as e: - FreeCAD.Console.PrintError(f"Save failed: {e}\n") - return None - - def create_document_for_item(self, item: Dict[str, Any], save: bool = True): - """Create a new FreeCAD document for a database item.""" - part_number = item.get("part_number", "") - description = item.get("description", "") - item_type = item.get("item_type", "part") - - if not part_number: - return None - - doc = FreeCAD.newDocument(part_number) - safe_name = "_" + part_number - - if item_type == "assembly": - # Create an Assembly object for assembly items (FreeCAD 1.0+) - try: - assembly_obj = doc.addObject("Assembly::AssemblyObject", safe_name) - assembly_obj.Label = part_number - set_silo_properties( - assembly_obj, - { - "SiloItemId": item.get("id", ""), - "SiloPartNumber": part_number, - "SiloRevision": item.get("current_revision", 1), - "SiloItemType": item_type, - }, - ) - except Exception as e: - # Fallback to App::Part if Assembly workbench not available - FreeCAD.Console.PrintWarning( - f"Assembly workbench not available, using App::Part: {e}\n" - ) - part_obj = doc.addObject("App::Part", safe_name) - part_obj.Label = part_number - set_silo_properties( - part_obj, - { - "SiloItemId": item.get("id", ""), - "SiloPartNumber": part_number, - "SiloRevision": item.get("current_revision", 1), - "SiloItemType": item_type, - }, - ) - else: - # Create a Part container for non-assembly items - part_obj = doc.addObject("App::Part", safe_name) - part_obj.Label = part_number - - set_silo_properties( - part_obj, - { - "SiloItemId": item.get("id", ""), - "SiloPartNumber": part_number, - "SiloRevision": item.get("current_revision", 1), - "SiloItemType": item_type, - }, - ) - - # Add a Body for parts (not assemblies) - body_label = sanitize_filename(description) if description else "Body" - body = doc.addObject("PartDesign::Body", "_" + body_label) - body.Label = body_label - part_obj.addObject(body) - - doc.recompute() - - if save: - file_path = get_cad_file_path(part_number, description) - file_path.parent.mkdir(parents=True, exist_ok=True) - doc.saveAs(str(file_path)) - - return doc - - def open_item(self, part_number: str): - """Open or create item document.""" - existing_path = find_file_by_part_number(part_number) - - if existing_path and existing_path.exists(): - return FreeCAD.openDocument(str(existing_path)) - - try: - item = self.client.get_item(part_number) - return self.create_document_for_item(item, save=True) - except Exception as e: - FreeCAD.Console.PrintError(f"Failed to open: {e}\n") - return None - - def upload_file( - self, part_number: str, file_path: str, comment: str = "Auto-save" - ) -> Optional[Dict]: - """Upload file to MinIO.""" - try: - doc = FreeCAD.openDocument(file_path) - if not doc: - return None - properties = collect_document_properties(doc) - FreeCAD.closeDocument(doc.Name) - - return self.client._upload_file(part_number, file_path, properties, comment) - except Exception as e: - FreeCAD.Console.PrintError(f"Upload failed: {e}\n") - return None - - def download_file(self, part_number: str) -> Optional[Path]: - """Download latest file from MinIO.""" - try: - item = self.client.get_item(part_number) - file_path = get_cad_file_path(part_number, item.get("description", "")) - file_path.parent.mkdir(parents=True, exist_ok=True) - - revisions = self.client.get_revisions(part_number) - for rev in revisions: - if rev.get("file_key"): - if self.client._download_file( - part_number, rev["revision_number"], str(file_path) - ): - return file_path - return None - except Exception as e: - FreeCAD.Console.PrintError(f"Download failed: {e}\n") - return None - - -_sync = SiloSync() - - -# ============================================================================ -# COMMANDS -# ============================================================================ - - -class Silo_Open: - """Open item - combined search and open dialog.""" - - def GetResources(self): - return { - "MenuText": "Open", - "ToolTip": "Search and open items (Ctrl+O)", - "Pixmap": _icon("open"), - } - - def Activated(self): - from PySide import QtCore, QtGui - - dialog = QtGui.QDialog() - dialog.setWindowTitle("Silo - Open Item") - dialog.setMinimumWidth(700) - dialog.setMinimumHeight(500) - - layout = QtGui.QVBoxLayout(dialog) - - # Search row - search_layout = QtGui.QHBoxLayout() - search_input = QtGui.QLineEdit() - search_input.setPlaceholderText("Search by part number or description...") - search_layout.addWidget(search_input) - layout.addLayout(search_layout) - - # Filters - filter_layout = QtGui.QHBoxLayout() - db_checkbox = QtGui.QCheckBox("Database") - db_checkbox.setChecked(True) - local_checkbox = QtGui.QCheckBox("Local Files") - local_checkbox.setChecked(True) - filter_layout.addWidget(db_checkbox) - filter_layout.addWidget(local_checkbox) - filter_layout.addStretch() - layout.addLayout(filter_layout) - - # Results table - results_table = QtGui.QTableWidget() - results_table.setColumnCount(5) - results_table.setHorizontalHeaderLabels( - ["Part Number", "Description", "Type", "Source", "Modified"] - ) - results_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - results_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - results_table.horizontalHeader().setStretchLastSection(True) - results_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) - layout.addWidget(results_table) - - results_data = [] - - def do_search(): - nonlocal results_data - search_term = search_input.text().strip() - results_data = [] - results_table.setRowCount(0) - - if db_checkbox.isChecked(): - try: - for item in _client.list_items(search=search_term): - results_data.append( - { - "part_number": item.get("part_number", ""), - "description": item.get("description", ""), - "item_type": item.get("item_type", ""), - "source": "database", - "modified": item.get("updated_at", "")[:10] - if item.get("updated_at") - else "", - "path": None, - } - ) - except Exception as e: - FreeCAD.Console.PrintWarning(f"DB search failed: {e}\n") - - if local_checkbox.isChecked(): - try: - for item in search_local_files(search_term): - existing = next( - (r for r in results_data if r["part_number"] == item["part_number"]), - None, - ) - if existing: - existing["source"] = "both" - existing["path"] = item.get("path") - else: - results_data.append( - { - "part_number": item.get("part_number", ""), - "description": item.get("description", ""), - "item_type": "", - "source": "local", - "modified": item.get("modified", "")[:10] - if item.get("modified") - else "", - "path": item.get("path"), - } - ) - except Exception as e: - FreeCAD.Console.PrintWarning(f"Local search failed: {e}\n") - - results_table.setRowCount(len(results_data)) - for row, data in enumerate(results_data): - results_table.setItem(row, 0, QtGui.QTableWidgetItem(data["part_number"])) - results_table.setItem(row, 1, QtGui.QTableWidgetItem(data["description"])) - results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"])) - results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"])) - results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"])) - results_table.resizeColumnsToContents() - - _open_after_close = [None] - - def open_selected(): - selected = results_table.selectedItems() - if not selected: - return - row = selected[0].row() - _open_after_close[0] = dict(results_data[row]) - dialog.accept() - - search_input.textChanged.connect(lambda: do_search()) - results_table.doubleClicked.connect(open_selected) - - # Buttons - btn_layout = QtGui.QHBoxLayout() - open_btn = QtGui.QPushButton("Open") - open_btn.clicked.connect(open_selected) - cancel_btn = QtGui.QPushButton("Cancel") - cancel_btn.clicked.connect(dialog.reject) - btn_layout.addStretch() - btn_layout.addWidget(open_btn) - btn_layout.addWidget(cancel_btn) - layout.addLayout(btn_layout) - - do_search() - dialog.exec_() - - # Open the document AFTER the dialog has fully closed so that - # heavy document loads (especially Assembly files) don't run - # inside the dialog's nested event loop, which can cause crashes. - data = _open_after_close[0] - if data is not None: - if data.get("path"): - FreeCAD.openDocument(data["path"]) - else: - _sync.open_item(data["part_number"]) - - def IsActive(self): - return True - - -class Silo_New: - """Create new item with part number.""" - - def GetResources(self): - return { - "MenuText": "New", - "ToolTip": "Create new item (Ctrl+N)", - "Pixmap": _icon("new"), - } - - def Activated(self): - from PySide import QtGui - - sel = FreeCADGui.Selection.getSelection() - - # Category selection - try: - schema = _client.get_schema() - categories = schema.get("segments", []) - cat_segment = next((s for s in categories if s.get("name") == "category"), None) - if cat_segment and cat_segment.get("values"): - cat_list = [f"{k} - {v}" for k, v in sorted(cat_segment["values"].items())] - category_str, ok = QtGui.QInputDialog.getItem( - None, "New Item", "Category:", cat_list, 0, False - ) - if not ok: - return - category = category_str.split(" - ")[0] - else: - category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:") - if not ok: - return - except Exception: - category, ok = QtGui.QInputDialog.getText(None, "New Item", "Category code:") - if not ok: - return - - # Description - default_desc = sel[0].Label if sel else "" - description, ok = QtGui.QInputDialog.getText( - None, "New Item", "Description:", text=default_desc - ) - if not ok: - return - - # Optional project tagging - selected_projects = [] - try: - projects = _client.get_projects() - if projects: - project_codes = [p.get("code", "") for p in projects if p.get("code")] - if project_codes: - # Multi-select dialog for projects - dialog = QtGui.QDialog() - dialog.setWindowTitle("Tag with Projects (Optional)") - dialog.setMinimumWidth(300) - layout = QtGui.QVBoxLayout(dialog) - - label = QtGui.QLabel("Select projects to tag this item with:") - layout.addWidget(label) - - list_widget = QtGui.QListWidget() - list_widget.setSelectionMode(QtGui.QAbstractItemView.MultiSelection) - for code in project_codes: - list_widget.addItem(code) - layout.addWidget(list_widget) - - btn_layout = QtGui.QHBoxLayout() - skip_btn = QtGui.QPushButton("Skip") - ok_btn = QtGui.QPushButton("Tag Selected") - btn_layout.addWidget(skip_btn) - btn_layout.addWidget(ok_btn) - layout.addLayout(btn_layout) - - skip_btn.clicked.connect(dialog.reject) - ok_btn.clicked.connect(dialog.accept) - - if dialog.exec_() == QtGui.QDialog.Accepted: - selected_projects = [item.text() for item in list_widget.selectedItems()] - except Exception as e: - FreeCAD.Console.PrintWarning(f"Could not fetch projects: {e}\n") - - try: - result = _client.create_item( - "kindred-rd", - category, - description, - projects=selected_projects if selected_projects else None, - ) - part_number = result["part_number"] - - if sel: - # Tag selected object - obj = sel[0] - set_silo_properties( - obj, - { - "SiloItemId": result.get("id", ""), - "SiloPartNumber": part_number, - "SiloRevision": 1, - }, - ) - obj.Label = part_number - _sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True) - else: - # Create new document - _sync.create_document_for_item(result, save=True) - - msg = f"Part number: {part_number}" - if selected_projects: - msg += f"\nTagged with projects: {', '.join(selected_projects)}" - - FreeCAD.Console.PrintMessage(f"Created: {part_number}\n") - QtGui.QMessageBox.information(None, "Item Created", msg) - - except Exception as e: - QtGui.QMessageBox.critical(None, "Error", str(e)) - - def IsActive(self): - return True - - -class Silo_Save: - """Save locally and upload to MinIO.""" - - def GetResources(self): - return { - "MenuText": "Save", - "ToolTip": "Save locally and upload to MinIO (Ctrl+S)", - "Pixmap": _icon("save"), - } - - def Activated(self): - doc = FreeCAD.ActiveDocument - if not doc: - FreeCAD.Console.PrintError("No active document\n") - return - - obj = get_tracked_object(doc) - - # If not tracked, just do a regular FreeCAD save - if not obj: - if doc.FileName: - doc.save() - FreeCAD.Console.PrintMessage(f"Saved: {doc.FileName}\n") - else: - FreeCADGui.runCommand("Std_SaveAs", 0) - return - - part_number = obj.SiloPartNumber - - # Check if document has unsaved changes - gui_doc = FreeCADGui.getDocument(doc.Name) - is_modified = gui_doc.Modified if gui_doc else True - FreeCAD.Console.PrintMessage(f"[DEBUG] Modified={is_modified}, FileName={doc.FileName}\n") - - if gui_doc and not is_modified and doc.FileName: - FreeCAD.Console.PrintMessage("No changes to save.\n") - return - - # Collect properties BEFORE saving to avoid dirtying the document - # (accessing Shape properties can trigger recompute) - FreeCAD.Console.PrintMessage("[DEBUG] Collecting properties...\n") - properties = collect_document_properties(doc) - - # Check modified state after collecting properties - is_modified_after_props = gui_doc.Modified if gui_doc else True - FreeCAD.Console.PrintMessage( - f"[DEBUG] After collect_properties: Modified={is_modified_after_props}\n" - ) - - # Save locally - FreeCAD.Console.PrintMessage("[DEBUG] Saving to canonical path...\n") - file_path = _sync.save_to_canonical_path(doc, force_rename=True) - if not file_path: - # Fallback to regular save if canonical path fails - if doc.FileName: - doc.save() - file_path = Path(doc.FileName) - else: - FreeCAD.Console.PrintError("Could not determine save path\n") - return - - # Check modified state after save - is_modified_after_save = gui_doc.Modified if gui_doc else True - FreeCAD.Console.PrintMessage(f"[DEBUG] After save: Modified={is_modified_after_save}\n") - - # Force clear modified flag if save succeeded (needed for assemblies) - if is_modified_after_save and gui_doc: - FreeCAD.Console.PrintMessage("[DEBUG] Attempting to clear Modified flag...\n") - try: - gui_doc.Modified = False - FreeCAD.Console.PrintMessage( - f"[DEBUG] After force clear: Modified={gui_doc.Modified}\n" - ) - except Exception as e: - FreeCAD.Console.PrintMessage(f"[DEBUG] Could not clear Modified: {e}\n") - - FreeCAD.Console.PrintMessage(f"Saved: {file_path}\n") - - # Try to upload to MinIO - try: - result = _client._upload_file(part_number, str(file_path), properties, "Auto-save") - - new_rev = result["revision_number"] - FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n") - - # Check modified state after upload - is_modified_after_upload = gui_doc.Modified if gui_doc else True - FreeCAD.Console.PrintMessage( - f"[DEBUG] After upload: Modified={is_modified_after_upload}\n" - ) - - except Exception as e: - FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n") - FreeCAD.Console.PrintMessage("File saved locally but not uploaded.\n") - - def IsActive(self): - return FreeCAD.ActiveDocument is not None - - -class Silo_Commit: - """Save as new revision with comment.""" - - def GetResources(self): - return { - "MenuText": "Commit", - "ToolTip": "Save as new revision with comment (Ctrl+Shift+S)", - "Pixmap": _icon("commit"), - } - - def Activated(self): - from PySide import QtGui - - doc = FreeCAD.ActiveDocument - if not doc: - FreeCAD.Console.PrintError("No active document\n") - return - - obj = get_tracked_object(doc) - if not obj: - FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n") - return - - part_number = obj.SiloPartNumber - - comment, ok = QtGui.QInputDialog.getText(None, "Commit", "Revision comment:") - if not ok: - return - - # Collect properties BEFORE saving to avoid dirtying the document - properties = collect_document_properties(doc) - - try: - file_path = _sync.save_to_canonical_path(doc, force_rename=True) - if not file_path: - return - - result = _client._upload_file(part_number, str(file_path), properties, comment) - - new_rev = result["revision_number"] - FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n") - - except Exception as e: - FreeCAD.Console.PrintError(f"Commit failed: {e}\n") - - def IsActive(self): - return FreeCAD.ActiveDocument is not None - - -def _check_pull_conflicts(part_number, local_path, doc=None): - """Check for conflicts between local file and server state. - - Returns a list of conflict description strings, or an empty list if clean. - """ - conflicts = [] - - # Check for unsaved changes in an open document - if doc is not None and doc.IsModified(): - conflicts.append("Document has unsaved local changes.") - - # Check local revision vs server latest - if doc is not None: - obj = get_tracked_object(doc) - if obj and hasattr(obj, "SiloRevision"): - local_rev = getattr(obj, "SiloRevision", 0) - latest = _client.latest_file_revision(part_number) - if latest and local_rev and latest["revision_number"] > local_rev: - conflicts.append( - f"Local file is at revision {local_rev}, " - f"server has revision {latest['revision_number']}." - ) - - # Check local file mtime vs server timestamp - if local_path and local_path.exists(): - import datetime - - local_mtime = datetime.datetime.fromtimestamp( - local_path.stat().st_mtime, tz=datetime.timezone.utc - ) - try: - item = _client.get_item(part_number) - server_updated = item.get("updated_at", "") - if server_updated: - # Parse ISO format timestamp - server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00")) - if server_dt > local_mtime: - conflicts.append("Server version is newer than local file.") - except Exception: - pass - - return conflicts - - -class SiloPullDialog: - """Dialog for selecting which revision to pull.""" - - def __init__(self, part_number, revisions, parent=None): - from PySide import QtCore, QtGui - - self._selected_revision = None - - self._dialog = QtGui.QDialog(parent) - self._dialog.setWindowTitle(f"Pull - {part_number}") - self._dialog.setMinimumWidth(600) - self._dialog.setMinimumHeight(350) - - layout = QtGui.QVBoxLayout(self._dialog) - - info = QtGui.QLabel(f"Select a revision to download for {part_number}:") - layout.addWidget(info) - - # Revision table - self._table = QtGui.QTableWidget() - self._table.setColumnCount(5) - self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"]) - self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) - self._table.verticalHeader().setVisible(False) - header = self._table.horizontalHeader() - header.setStretchLastSection(True) - - # Populate rows - file_revisions = [] - self._table.setRowCount(len(revisions)) - for i, rev in enumerate(revisions): - rev_num = rev.get("revision_number", "") - date = rev.get("created_at", "")[:16].replace("T", " ") - comment = rev.get("comment", "") - status = rev.get("status", "") - has_file = "\u2713" if rev.get("file_key") else "" - - self._table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev_num))) - self._table.setItem(i, 1, QtGui.QTableWidgetItem(date)) - self._table.setItem(i, 2, QtGui.QTableWidgetItem(comment)) - self._table.setItem(i, 3, QtGui.QTableWidgetItem(status)) - file_item = QtGui.QTableWidgetItem(has_file) - file_item.setTextAlignment(QtCore.Qt.AlignCenter) - self._table.setItem(i, 4, file_item) - - if rev.get("file_key"): - file_revisions.append(i) - - self._table.resizeColumnsToContents() - layout.addWidget(self._table) - - # Pre-select the latest revision with a file - if file_revisions: - self._table.selectRow(file_revisions[0]) - - # Store revision data for lookup - self._revisions = revisions - - # Buttons - btn_layout = QtGui.QHBoxLayout() - btn_layout.addStretch() - download_btn = QtGui.QPushButton("Download") - cancel_btn = QtGui.QPushButton("Cancel") - download_btn.clicked.connect(self._on_download) - cancel_btn.clicked.connect(self._dialog.reject) - self._table.doubleClicked.connect(self._on_download) - btn_layout.addWidget(download_btn) - btn_layout.addWidget(cancel_btn) - layout.addLayout(btn_layout) - - def _on_download(self): - row = self._table.currentRow() - if row < 0: - return - rev = self._revisions[row] - if not rev.get("file_key"): - from PySide import QtGui - - QtGui.QMessageBox.information( - self._dialog, "Pull", "Selected revision has no file attached." - ) - return - self._selected_revision = rev - self._dialog.accept() - - def exec_(self): - if self._dialog.exec_() == 1: # QDialog.Accepted - return self._selected_revision - return None - - -class Silo_Pull: - """Download from MinIO / sync from database.""" - - def GetResources(self): - return { - "MenuText": "Pull", - "ToolTip": "Download from MinIO with revision selection", - "Pixmap": _icon("pull"), - } - - def Activated(self): - from PySide import QtGui - - doc = FreeCAD.ActiveDocument - part_number = None - obj = None - - if doc: - obj = get_tracked_object(doc) - if obj: - part_number = obj.SiloPartNumber - - if not part_number: - part_number, ok = QtGui.QInputDialog.getText(None, "Pull", "Part number:") - if not ok or not part_number: - return - part_number = part_number.strip().upper() - - # Fetch revisions from server - try: - revisions = _client.get_revisions(part_number) - except Exception as e: - QtGui.QMessageBox.warning(None, "Pull", f"Cannot reach server: {e}") - return - - existing_local = find_file_by_part_number(part_number) - - # If no revisions have files, fall back to create-from-database - has_any_file = any(r.get("file_key") for r in revisions) - - if not has_any_file: - if existing_local: - FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n") - FreeCAD.openDocument(str(existing_local)) - else: - try: - item = _client.get_item(part_number) - new_doc = _sync.create_document_for_item(item, save=True) - if new_doc: - FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n") - else: - QtGui.QMessageBox.warning( - None, - "Pull", - f"Failed to create document for {part_number}", - ) - except Exception as e: - QtGui.QMessageBox.warning(None, "Pull", f"Failed: {e}") - return - - # Conflict detection - conflicts = _check_pull_conflicts(part_number, existing_local, doc) - if conflicts: - detail = "\n".join(f" - {c}" for c in conflicts) - reply = QtGui.QMessageBox.warning( - None, - "Pull - Conflicts Detected", - f"Potential conflicts found:\n{detail}\n\n" - "Download anyway and overwrite local file?", - QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, - ) - if reply != QtGui.QMessageBox.Yes: - return - - # Show revision selection dialog - dlg = SiloPullDialog(part_number, revisions) - selected = dlg.exec_() - if selected is None: - return - - rev_num = selected["revision_number"] - - # Determine destination path - try: - item = _client.get_item(part_number) - except Exception: - item = {} - dest_path = get_cad_file_path(part_number, item.get("description", "")) - dest_path.parent.mkdir(parents=True, exist_ok=True) - - # Download with progress - progress = QtGui.QProgressDialog( - f"Downloading {part_number} rev {rev_num}...", "Cancel", 0, 100 - ) - progress.setWindowModality(2) # Qt.WindowModal - progress.setMinimumDuration(0) - progress.setValue(0) - - def on_progress(downloaded, total): - if progress.wasCanceled(): - return - if total > 0: - pct = int(downloaded * 100 / total) - progress.setValue(min(pct, 99)) - else: - # Indeterminate - pulse between 0-90 - progress.setValue(min(int(downloaded / 1024) % 90, 89)) - QtGui.QApplication.processEvents() - - try: - ok = _client._download_file( - part_number, rev_num, str(dest_path), progress_callback=on_progress - ) - except Exception as e: - progress.close() - QtGui.QMessageBox.warning(None, "Pull", f"Download failed: {e}") - return - - progress.setValue(100) - progress.close() - - if not ok: - QtGui.QMessageBox.warning(None, "Pull", "Download failed.") - return - - FreeCAD.Console.PrintMessage(f"Pulled revision {rev_num} of {part_number}\n") - - # Close existing document if open, then reopen - if doc and doc.FileName == str(dest_path): - FreeCAD.closeDocument(doc.Name) - FreeCAD.openDocument(str(dest_path)) - - # Update SiloRevision property on the tracked object - new_doc = FreeCAD.ActiveDocument - if new_doc: - new_obj = get_tracked_object(new_doc) - if new_obj and hasattr(new_obj, "SiloRevision"): - new_obj.SiloRevision = rev_num - new_doc.save() - - def IsActive(self): - return True - - -class Silo_Push: - """Upload local files to MinIO.""" - - def GetResources(self): - return { - "MenuText": "Push", - "ToolTip": "Upload local files that aren't in MinIO", - "Pixmap": _icon("push"), - } - - def Activated(self): - from datetime import datetime, timezone - - from PySide import QtGui - - # Find files that need uploading (no server file, or local is newer) - local_files = search_local_files() - unuploaded = [] - - for lf in local_files: - pn = lf["part_number"] - try: - _client.get_item(pn) # Check if in DB - server_rev = _client.latest_file_revision(pn) - if not server_rev: - # No file on server at all - unuploaded.append(lf) - else: - # Compare local mtime against server revision timestamp - try: - local_mtime = os.path.getmtime(lf["path"]) - server_time_str = server_rev.get("created_at", "") - if server_time_str: - server_dt = datetime.fromisoformat( - server_time_str.replace("Z", "+00:00") - ) - local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc) - if local_dt > server_dt: - unuploaded.append(lf) - else: - # Can't parse server time, assume needs upload - unuploaded.append(lf) - except Exception: - # On any comparison error, include it - unuploaded.append(lf) - except Exception: - pass # Not in DB, skip - - if not unuploaded: - QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.") - return - - msg = f"Found {len(unuploaded)} files to upload:\n\n" - for item in unuploaded[:10]: - msg += f" {item['part_number']}\n" - if len(unuploaded) > 10: - msg += f" ... and {len(unuploaded) - 10} more\n" - msg += "\nUpload?" - - reply = QtGui.QMessageBox.question( - None, "Push", msg, QtGui.QMessageBox.Yes | QtGui.QMessageBox.No - ) - if reply != QtGui.QMessageBox.Yes: - return - - uploaded = 0 - for item in unuploaded: - result = _sync.upload_file(item["part_number"], item["path"], "Synced from local") - if result: - uploaded += 1 - - QtGui.QMessageBox.information(None, "Push", f"Uploaded {uploaded} files.") - - def IsActive(self): - return True - - -class Silo_Info: - """Show item status and revision history.""" - - def GetResources(self): - return { - "MenuText": "Info", - "ToolTip": "Show item status and revision history", - "Pixmap": _icon("info"), - } - - def Activated(self): - from PySide import QtGui - - doc = FreeCAD.ActiveDocument - if not doc: - FreeCAD.Console.PrintError("No active document\n") - return - - obj = get_tracked_object(doc) - if not obj: - FreeCAD.Console.PrintError("No tracked object\n") - return - - part_number = obj.SiloPartNumber - - try: - item = _client.get_item(part_number) - revisions = _client.get_revisions(part_number) - - # Get projects for item - try: - projects = _client.get_item_projects(part_number) - project_codes = [p.get("code", "") for p in projects if p.get("code")] - except Exception: - project_codes = [] - - msg = f"

{part_number}

" - msg += f"

Type: {item.get('item_type', '-')}

" - msg += f"

Description: {item.get('description', '-')}

" - msg += ( - f"

Projects: {', '.join(project_codes) if project_codes else 'None'}

" - ) - msg += f"

Current Revision: {item.get('current_revision', 1)}

" - msg += f"

Local Revision: {getattr(obj, 'SiloRevision', '-')}

" - - has_file, _ = _client.has_file(part_number) - msg += f"

File in MinIO: {'Yes' if has_file else 'No'}

" - - # Show current revision status - if revisions: - current_status = revisions[0].get("status", "draft") - current_labels = revisions[0].get("labels", []) - msg += f"

Current Status: {current_status}

" - if current_labels: - msg += f"

Labels: {', '.join(current_labels)}

" - - msg += "

Revision History

" - msg += "" - for rev in revisions: - file_icon = "✓" if rev.get("file_key") else "-" - comment = rev.get("comment", "") or "-" - date = rev.get("created_at", "")[:10] - status = rev.get("status", "draft") - msg += f"" - msg += "
RevStatusDateFileComment
{rev['revision_number']}{status}{date}{file_icon}{comment}
" - - dialog = QtGui.QMessageBox() - dialog.setWindowTitle("Item Info") - dialog.setTextFormat(QtGui.Qt.RichText) - dialog.setText(msg) - dialog.exec_() - - except Exception as e: - QtGui.QMessageBox.warning(None, "Info", f"Failed to get info: {e}") - - def IsActive(self): - return FreeCAD.ActiveDocument is not None - - -class Silo_TagProjects: - """Manage project tags for an item.""" - - def GetResources(self): - return { - "MenuText": "Tag Projects", - "ToolTip": "Add or remove project tags for an item", - "Pixmap": _icon("tag"), - } - - def Activated(self): - from PySide import QtGui - - doc = FreeCAD.ActiveDocument - if not doc: - FreeCAD.Console.PrintError("No active document\n") - return - - obj = get_tracked_object(doc) - if not obj: - FreeCAD.Console.PrintError("No tracked object\n") - return - - part_number = obj.SiloPartNumber - - try: - # Get current projects for item - current_projects = _client.get_item_projects(part_number) - current_codes = {p.get("code", "") for p in current_projects if p.get("code")} - - # Get all available projects - all_projects = _client.get_projects() - all_codes = [p.get("code", "") for p in all_projects if p.get("code")] - - if not all_codes: - QtGui.QMessageBox.information( - None, - "Tag Projects", - "No projects available. Create projects first.", - ) - return - - # Multi-select dialog - dialog = QtGui.QDialog() - dialog.setWindowTitle(f"Tag Projects for {part_number}") - dialog.setMinimumWidth(350) - layout = QtGui.QVBoxLayout(dialog) - - label = QtGui.QLabel("Select projects to associate with this item:") - layout.addWidget(label) - - list_widget = QtGui.QListWidget() - list_widget.setSelectionMode(QtGui.QAbstractItemView.MultiSelection) - for code in all_codes: - item = QtGui.QListWidgetItem(code) - list_widget.addItem(item) - if code in current_codes: - item.setSelected(True) - layout.addWidget(list_widget) - - btn_layout = QtGui.QHBoxLayout() - cancel_btn = QtGui.QPushButton("Cancel") - save_btn = QtGui.QPushButton("Save") - btn_layout.addWidget(cancel_btn) - btn_layout.addWidget(save_btn) - layout.addLayout(btn_layout) - - cancel_btn.clicked.connect(dialog.reject) - save_btn.clicked.connect(dialog.accept) - - if dialog.exec_() == QtGui.QDialog.Accepted: - selected = [item.text() for item in list_widget.selectedItems()] - - # Add new tags - to_add = [c for c in selected if c not in current_codes] - if to_add: - _client.add_item_projects(part_number, to_add) - - # Note: removing tags would require a separate API call per project - # For simplicity, we just add new ones here - - msg = f"Updated project tags for {part_number}" - if to_add: - msg += f"\nAdded: {', '.join(to_add)}" - - QtGui.QMessageBox.information(None, "Tag Projects", msg) - FreeCAD.Console.PrintMessage(f"{msg}\n") - - except Exception as e: - QtGui.QMessageBox.warning(None, "Tag Projects", f"Failed: {e}") - - def IsActive(self): - return FreeCAD.ActiveDocument is not None - - -class Silo_Rollback: - """Rollback to a previous revision.""" - - def GetResources(self): - return { - "MenuText": "Rollback", - "ToolTip": "Rollback to a previous revision", - "Pixmap": _icon("rollback"), - } - - def Activated(self): - from PySide import QtCore, QtGui - - doc = FreeCAD.ActiveDocument - if not doc: - FreeCAD.Console.PrintError("No active document\n") - return - - obj = get_tracked_object(doc) - if not obj: - FreeCAD.Console.PrintError("No tracked object\n") - return - - part_number = obj.SiloPartNumber - - try: - revisions = _client.get_revisions(part_number) - if len(revisions) < 2: - QtGui.QMessageBox.information( - None, "Rollback", "No previous revisions to rollback to." - ) - return - - # Build revision list for selection (exclude current/latest) - current_rev = revisions[0]["revision_number"] - prev_revisions = revisions[1:] # All except latest - - # Create selection dialog - dialog = QtGui.QDialog() - dialog.setWindowTitle(f"Rollback {part_number}") - dialog.setMinimumWidth(500) - dialog.setMinimumHeight(300) - layout = QtGui.QVBoxLayout(dialog) - - label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):") - layout.addWidget(label) - - # Revision table - table = QtGui.QTableWidget() - table.setColumnCount(4) - table.setHorizontalHeaderLabels(["Rev", "Status", "Date", "Comment"]) - table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - table.setRowCount(len(prev_revisions)) - table.horizontalHeader().setStretchLastSection(True) - - for i, rev in enumerate(prev_revisions): - table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"]))) - table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft"))) - table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])) - table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")) - - table.resizeColumnsToContents() - layout.addWidget(table) - - # Comment field - comment_label = QtGui.QLabel("Rollback comment (optional):") - layout.addWidget(comment_label) - comment_input = QtGui.QLineEdit() - comment_input.setPlaceholderText("Reason for rollback...") - layout.addWidget(comment_input) - - # Buttons - btn_layout = QtGui.QHBoxLayout() - cancel_btn = QtGui.QPushButton("Cancel") - rollback_btn = QtGui.QPushButton("Rollback") - btn_layout.addStretch() - btn_layout.addWidget(cancel_btn) - btn_layout.addWidget(rollback_btn) - layout.addLayout(btn_layout) - - selected_rev = [None] - - def on_rollback(): - selected = table.selectedItems() - if not selected: - QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision") - return - selected_rev[0] = int(table.item(selected[0].row(), 0).text()) - dialog.accept() - - cancel_btn.clicked.connect(dialog.reject) - rollback_btn.clicked.connect(on_rollback) - - if dialog.exec_() == QtGui.QDialog.Accepted and selected_rev[0]: - target_rev = selected_rev[0] - comment = comment_input.text().strip() - - # Confirm - reply = QtGui.QMessageBox.question( - None, - "Confirm Rollback", - f"Create new revision by rolling back to Rev {target_rev}?\n\n" - "This will copy properties and file reference from the selected revision.", - QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, - ) - if reply != QtGui.QMessageBox.Yes: - return - - # Perform rollback - result = _client.rollback_revision(part_number, target_rev, comment) - new_rev = result["revision_number"] - - FreeCAD.Console.PrintMessage( - f"Created revision {new_rev} (rollback from {target_rev})\n" - ) - QtGui.QMessageBox.information( - None, - "Rollback Complete", - f"Created revision {new_rev} from rollback to Rev {target_rev}.\n\n" - "Use 'Pull' to download the rolled-back file.", - ) - - except Exception as e: - QtGui.QMessageBox.warning(None, "Rollback", f"Failed: {e}") - - def IsActive(self): - return FreeCAD.ActiveDocument is not None - - -class Silo_SetStatus: - """Set revision status (draft, review, released, obsolete).""" - - def GetResources(self): - return { - "MenuText": "Set Status", - "ToolTip": "Set the status of the current revision", - "Pixmap": _icon("status"), - } - - def Activated(self): - from PySide import QtGui - - doc = FreeCAD.ActiveDocument - if not doc: - FreeCAD.Console.PrintError("No active document\n") - return - - obj = get_tracked_object(doc) - if not obj: - FreeCAD.Console.PrintError("No tracked object\n") - return - - part_number = obj.SiloPartNumber - local_rev = getattr(obj, "SiloRevision", 1) - - try: - # Get current revision info - revisions = _client.get_revisions(part_number) - current_rev = revisions[0] if revisions else None - if not current_rev: - QtGui.QMessageBox.warning(None, "Set Status", "No revisions found") - return - - current_status = current_rev.get("status", "draft") - rev_num = current_rev["revision_number"] - - # Status selection - statuses = ["draft", "review", "released", "obsolete"] - status, ok = QtGui.QInputDialog.getItem( - None, - "Set Revision Status", - f"Set status for Rev {rev_num} (current: {current_status}):", - statuses, - statuses.index(current_status), - False, - ) - - if not ok or status == current_status: - return - - # Update status - _client.update_revision(part_number, rev_num, status=status) - - FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n") - QtGui.QMessageBox.information( - None, "Status Updated", f"Revision {rev_num} status set to '{status}'" - ) - - except Exception as e: - QtGui.QMessageBox.warning(None, "Set Status", f"Failed: {e}") - - def IsActive(self): - return FreeCAD.ActiveDocument is not None - - -class Silo_Settings: - """Configure Silo connection settings.""" - - def GetResources(self): - return { - "MenuText": "Settings", - "ToolTip": "Configure Silo API URL and SSL settings", - "Pixmap": _icon("info"), - } - - def Activated(self): - from PySide import QtCore, QtGui - - param = FreeCAD.ParamGet(_PREF_GROUP) - - dialog = QtGui.QDialog() - dialog.setWindowTitle("Silo Settings") - dialog.setMinimumWidth(450) - - layout = QtGui.QVBoxLayout(dialog) - - # URL - url_label = QtGui.QLabel("Silo API URL:") - layout.addWidget(url_label) - - url_input = QtGui.QLineEdit() - url_input.setPlaceholderText("http://localhost:8080/api") - current_url = param.GetString("ApiUrl", "") - if current_url: - url_input.setText(current_url) - else: - env_url = os.environ.get("SILO_API_URL", "") - if env_url: - url_input.setText(env_url) - layout.addWidget(url_input) - - url_hint = QtGui.QLabel( - "Full URL with path (e.g. http://localhost:8080/api) or just the " - "hostname (e.g. https://silo.kindred.internal) and /api is " - "appended automatically. Leave empty for SILO_API_URL env var." - ) - url_hint.setWordWrap(True) - url_hint.setStyleSheet("color: #888; font-size: 11px;") - layout.addWidget(url_hint) - - layout.addSpacing(10) - - # SSL - ssl_checkbox = QtGui.QCheckBox("Verify SSL certificates") - ssl_checkbox.setChecked(param.GetBool("SslVerify", True)) - layout.addWidget(ssl_checkbox) - - ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.") - ssl_hint.setWordWrap(True) - ssl_hint.setStyleSheet("color: #888; font-size: 11px;") - layout.addWidget(ssl_hint) - - layout.addSpacing(5) - - # Custom CA certificate - cert_label = QtGui.QLabel("Custom CA certificate file:") - layout.addWidget(cert_label) - - cert_row = QtGui.QHBoxLayout() - cert_input = QtGui.QLineEdit() - cert_input.setPlaceholderText("(Use system CA certificates)") - current_cert = param.GetString("SslCertPath", "") - if current_cert: - cert_input.setText(current_cert) - cert_browse = QtGui.QPushButton("Browse...") - cert_row.addWidget(cert_input) - cert_row.addWidget(cert_browse) - layout.addLayout(cert_row) - - cert_hint = QtGui.QLabel( - "Path to a PEM/CRT file for internal CAs. Leave empty for system certificates only." - ) - cert_hint.setWordWrap(True) - cert_hint.setStyleSheet("color: #888; font-size: 11px;") - layout.addWidget(cert_hint) - - def on_browse_cert(): - path, _ = QtGui.QFileDialog.getOpenFileName( - dialog, - "Select CA Certificate", - os.path.dirname(cert_input.text()) or "/etc/ssl/certs", - "Certificates (*.pem *.crt *.cer);;All Files (*)", - ) - if path: - cert_input.setText(path) - - cert_browse.clicked.connect(on_browse_cert) - - layout.addSpacing(10) - - # Authentication section - auth_heading = QtGui.QLabel("Authentication") - auth_heading.setTextFormat(QtCore.Qt.RichText) - layout.addWidget(auth_heading) - - auth_user = _get_auth_username() - auth_role = _get_auth_role() - auth_source = _get_auth_source() - has_token = bool(_get_auth_token()) - - if has_token and auth_user: - auth_parts = [f"Logged in as {auth_user}"] - if auth_role: - auth_parts.append(f"(role: {auth_role})") - if auth_source: - auth_parts.append(f"via {auth_source}") - auth_status_text = " ".join(auth_parts) - else: - auth_status_text = "Not logged in" - - auth_status_lbl = QtGui.QLabel(auth_status_text) - auth_status_lbl.setTextFormat(QtCore.Qt.RichText) - layout.addWidget(auth_status_lbl) - - # API token input - token_label = QtGui.QLabel("API Token:") - layout.addWidget(token_label) - - token_row = QtGui.QHBoxLayout() - token_input = QtGui.QLineEdit() - token_input.setEchoMode(QtGui.QLineEdit.Password) - token_input.setPlaceholderText("silo_... (paste token or use Login)") - current_token = param.GetString("ApiToken", "") - if current_token: - token_input.setText(current_token) - token_row.addWidget(token_input) - - token_show_btn = QtGui.QToolButton() - token_show_btn.setText("\U0001f441") - token_show_btn.setCheckable(True) - token_show_btn.setFixedSize(28, 28) - token_show_btn.setToolTip("Show/hide token") - - def on_toggle_show(checked): - if checked: - token_input.setEchoMode(QtGui.QLineEdit.Normal) - else: - token_input.setEchoMode(QtGui.QLineEdit.Password) - - token_show_btn.toggled.connect(on_toggle_show) - token_row.addWidget(token_show_btn) - layout.addLayout(token_row) - - token_hint = QtGui.QLabel( - "Paste an API token generated from the Silo web UI, " - "or use Login in the Database Auth panel to create one " - "automatically. Tokens can also be set via the " - "SILO_API_TOKEN environment variable." - ) - token_hint.setWordWrap(True) - token_hint.setStyleSheet("color: #888; font-size: 11px;") - layout.addWidget(token_hint) - - layout.addSpacing(4) - - clear_auth_btn = QtGui.QPushButton("Clear Token and Logout") - clear_auth_btn.setEnabled(has_token) - - def on_clear_auth(): - _clear_auth() - token_input.setText("") - auth_status_lbl.setText("Not logged in") - clear_auth_btn.setEnabled(False) - FreeCAD.Console.PrintMessage("Silo: API token and credentials cleared\n") - - clear_auth_btn.clicked.connect(on_clear_auth) - layout.addWidget(clear_auth_btn) - - layout.addSpacing(10) - - # Current effective values (read-only) - cert_display = param.GetString("SslCertPath", "") or "(system defaults)" - if has_token and auth_user: - auth_display = f"{auth_user} ({auth_role or 'unknown role'})" - if auth_source: - auth_display += f" via {auth_source}" - elif has_token: - auth_display = "token configured (user unknown)" - else: - auth_display = "not configured" - status_label = QtGui.QLabel( - f"Active URL: {_get_api_url()}
" - f"SSL verification: {'enabled' if _get_ssl_verify() else 'disabled'}
" - f"CA certificate: {cert_display}
" - f"Authentication: {auth_display}" - ) - status_label.setTextFormat(QtCore.Qt.RichText) - layout.addWidget(status_label) - - layout.addStretch() - - # Buttons - btn_layout = QtGui.QHBoxLayout() - save_btn = QtGui.QPushButton("Save") - cancel_btn = QtGui.QPushButton("Cancel") - btn_layout.addStretch() - btn_layout.addWidget(save_btn) - btn_layout.addWidget(cancel_btn) - layout.addLayout(btn_layout) - - def on_save(): - url = url_input.text().strip() - param.SetString("ApiUrl", url) - param.SetBool("SslVerify", ssl_checkbox.isChecked()) - cert_path = cert_input.text().strip() - param.SetString("SslCertPath", cert_path) - # Save API token if changed - new_token = token_input.text().strip() - old_token = param.GetString("ApiToken", "") - if new_token != old_token: - param.SetString("ApiToken", new_token) - if new_token and not old_token: - FreeCAD.Console.PrintMessage("Silo: API token configured\n") - elif not new_token and old_token: - _clear_auth() - FreeCAD.Console.PrintMessage("Silo: API token removed\n") - else: - FreeCAD.Console.PrintMessage("Silo: API token updated\n") - FreeCAD.Console.PrintMessage( - f"Silo settings saved. URL: {_get_api_url()}, " - f"SSL verify: {_get_ssl_verify()}, " - f"Cert: {cert_path or '(system)'}\n" - ) - dialog.accept() - - save_btn.clicked.connect(on_save) - cancel_btn.clicked.connect(dialog.reject) - - dialog.exec_() - - def IsActive(self): - return True - - -class Silo_BOM: - """View and manage Bill of Materials for the current item.""" - - def GetResources(self): - return { - "MenuText": "BOM", - "ToolTip": "View and manage Bill of Materials", - "Pixmap": _icon("bom"), - } - - def Activated(self): - from PySide import QtCore, QtGui - - doc = FreeCAD.ActiveDocument - if not doc: - FreeCAD.Console.PrintError("No active document\n") - return - - obj = get_tracked_object(doc) - if not obj: - FreeCAD.Console.PrintError("No tracked Silo item in active document.\n") - from PySide import QtGui as _qg - - _qg.QMessageBox.warning( - None, - "BOM", - "This document is not registered with Silo.\nUse Silo > New to register it first.", - ) - return - - part_number = obj.SiloPartNumber - - try: - item = _client.get_item(part_number) - except Exception as e: - QtGui.QMessageBox.warning(None, "BOM", f"Failed to get item info:\n{e}") - return - - # Build the dialog - dialog = QtGui.QDialog() - dialog.setWindowTitle(f"BOM - {part_number}") - dialog.setMinimumWidth(750) - dialog.setMinimumHeight(450) - layout = QtGui.QVBoxLayout(dialog) - - # Item header - header = QtGui.QLabel(f"{part_number} - {item.get('description', '')}") - layout.addWidget(header) - - # Tab widget - tabs = QtGui.QTabWidget() - layout.addWidget(tabs) - - # ── Tab 1: BOM (children of this item) ── - bom_widget = QtGui.QWidget() - bom_layout = QtGui.QVBoxLayout(bom_widget) - - bom_table = QtGui.QTableWidget() - bom_table.setColumnCount(7) - bom_table.setHorizontalHeaderLabels( - ["Part Number", "Description", "Type", "Qty", "Unit", "Ref Des", "Rev"] - ) - bom_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - bom_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - bom_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) - bom_table.horizontalHeader().setStretchLastSection(True) - bom_layout.addWidget(bom_table) - - # BOM button bar - bom_btn_layout = QtGui.QHBoxLayout() - add_btn = QtGui.QPushButton("Add") - edit_btn = QtGui.QPushButton("Edit") - remove_btn = QtGui.QPushButton("Remove") - bom_btn_layout.addWidget(add_btn) - bom_btn_layout.addWidget(edit_btn) - bom_btn_layout.addWidget(remove_btn) - bom_btn_layout.addStretch() - bom_layout.addLayout(bom_btn_layout) - - tabs.addTab(bom_widget, "BOM") - - # ── Tab 2: Where Used (parents of this item) ── - wu_widget = QtGui.QWidget() - wu_layout = QtGui.QVBoxLayout(wu_widget) - - wu_table = QtGui.QTableWidget() - wu_table.setColumnCount(5) - wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]) - wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) - wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) - wu_table.horizontalHeader().setStretchLastSection(True) - wu_layout.addWidget(wu_table) - - tabs.addTab(wu_widget, "Where Used") - - # ── Data loading ── - - bom_data = [] - - def load_bom(): - nonlocal bom_data - try: - bom_data = _client.get_bom(part_number) - except Exception as exc: - FreeCAD.Console.PrintWarning(f"BOM load error: {exc}\n") - bom_data = [] - - bom_table.setRowCount(len(bom_data)) - for row, entry in enumerate(bom_data): - bom_table.setItem( - row, 0, QtGui.QTableWidgetItem(entry.get("child_part_number", "")) - ) - bom_table.setItem( - row, 1, QtGui.QTableWidgetItem(entry.get("child_description", "")) - ) - bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))) - qty = entry.get("quantity") - bom_table.setItem( - row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "") - ) - bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")) - ref_des = entry.get("reference_designators") or [] - bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des))) - bom_table.setItem( - row, - 6, - QtGui.QTableWidgetItem(str(entry.get("effective_revision", ""))), - ) - bom_table.resizeColumnsToContents() - - def load_where_used(): - try: - wu_data = _client.get_bom_where_used(part_number) - except Exception as exc: - FreeCAD.Console.PrintWarning(f"Where-used load error: {exc}\n") - wu_data = [] - - wu_table.setRowCount(len(wu_data)) - for row, entry in enumerate(wu_data): - wu_table.setItem( - row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", "")) - ) - wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))) - qty = entry.get("quantity") - wu_table.setItem( - row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "") - ) - wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")) - ref_des = entry.get("reference_designators") or [] - wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des))) - wu_table.resizeColumnsToContents() - - # ── Button handlers ── - - def on_add(): - add_dlg = QtGui.QDialog(dialog) - add_dlg.setWindowTitle("Add BOM Entry") - add_dlg.setMinimumWidth(400) - al = QtGui.QFormLayout(add_dlg) - - child_input = QtGui.QLineEdit() - child_input.setPlaceholderText("e.g. F01-0001") - al.addRow("Child Part Number:", child_input) - - type_combo = QtGui.QComboBox() - type_combo.addItems(["component", "alternate", "reference"]) - al.addRow("Relationship Type:", type_combo) - - qty_input = QtGui.QLineEdit() - qty_input.setPlaceholderText("e.g. 4") - al.addRow("Quantity:", qty_input) - - unit_input = QtGui.QLineEdit() - unit_input.setPlaceholderText("e.g. ea, m, kg") - al.addRow("Unit:", unit_input) - - refdes_input = QtGui.QLineEdit() - refdes_input.setPlaceholderText("e.g. R1, R2, R3") - al.addRow("Ref Designators:", refdes_input) - - btn_box = QtGui.QDialogButtonBox( - QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel - ) - btn_box.accepted.connect(add_dlg.accept) - btn_box.rejected.connect(add_dlg.reject) - al.addRow(btn_box) - - if add_dlg.exec_() != QtGui.QDialog.Accepted: - return - - child_pn = child_input.text().strip() - if not child_pn: - return - - qty = None - qty_text = qty_input.text().strip() - if qty_text: - try: - qty = float(qty_text) - except ValueError: - QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.") - return - - unit = unit_input.text().strip() or None - rel_type = type_combo.currentText() - - ref_des = None - refdes_text = refdes_input.text().strip() - if refdes_text: - ref_des = [r.strip() for r in refdes_text.split(",") if r.strip()] - - try: - _client.add_bom_entry( - part_number, - child_pn, - quantity=qty, - unit=unit, - rel_type=rel_type, - ref_des=ref_des, - ) - load_bom() - except Exception as exc: - QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to add entry:\n{exc}") - - def on_edit(): - selected = bom_table.selectedItems() - if not selected: - return - row = selected[0].row() - if row < 0 or row >= len(bom_data): - return - - entry = bom_data[row] - child_pn = entry.get("child_part_number", "") - - edit_dlg = QtGui.QDialog(dialog) - edit_dlg.setWindowTitle(f"Edit BOM Entry - {child_pn}") - edit_dlg.setMinimumWidth(400) - el = QtGui.QFormLayout(edit_dlg) - - type_combo = QtGui.QComboBox() - type_combo.addItems(["component", "alternate", "reference"]) - current_type = entry.get("rel_type", "component") - idx = type_combo.findText(current_type) - if idx >= 0: - type_combo.setCurrentIndex(idx) - el.addRow("Relationship Type:", type_combo) - - qty_input = QtGui.QLineEdit() - qty = entry.get("quantity") - if qty is not None: - qty_input.setText(str(qty)) - el.addRow("Quantity:", qty_input) - - unit_input = QtGui.QLineEdit() - unit_input.setText(entry.get("unit") or "") - el.addRow("Unit:", unit_input) - - refdes_input = QtGui.QLineEdit() - ref_des = entry.get("reference_designators") or [] - refdes_input.setText(", ".join(ref_des)) - el.addRow("Ref Designators:", refdes_input) - - btn_box = QtGui.QDialogButtonBox( - QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel - ) - btn_box.accepted.connect(edit_dlg.accept) - btn_box.rejected.connect(edit_dlg.reject) - el.addRow(btn_box) - - if edit_dlg.exec_() != QtGui.QDialog.Accepted: - return - - new_qty = None - qty_text = qty_input.text().strip() - if qty_text: - try: - new_qty = float(qty_text) - except ValueError: - QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.") - return - - new_unit = unit_input.text().strip() or None - new_type = type_combo.currentText() - - new_ref_des = None - refdes_text = refdes_input.text().strip() - if refdes_text: - new_ref_des = [r.strip() for r in refdes_text.split(",") if r.strip()] - else: - new_ref_des = [] - - try: - _client.update_bom_entry( - part_number, - child_pn, - quantity=new_qty, - unit=new_unit, - rel_type=new_type, - ref_des=new_ref_des, - ) - load_bom() - except Exception as exc: - QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}") - - def on_remove(): - selected = bom_table.selectedItems() - if not selected: - return - row = selected[0].row() - if row < 0 or row >= len(bom_data): - return - - entry = bom_data[row] - child_pn = entry.get("child_part_number", "") - - reply = QtGui.QMessageBox.question( - dialog, - "Remove BOM Entry", - f"Remove {child_pn} from BOM?", - QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, - ) - if reply != QtGui.QMessageBox.Yes: - return - - try: - _client.delete_bom_entry(part_number, child_pn) - load_bom() - except Exception as exc: - QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}") - - add_btn.clicked.connect(on_add) - edit_btn.clicked.connect(on_edit) - remove_btn.clicked.connect(on_remove) - - # Close button - close_layout = QtGui.QHBoxLayout() - close_layout.addStretch() - close_btn = QtGui.QPushButton("Close") - close_btn.clicked.connect(dialog.accept) - close_layout.addWidget(close_btn) - layout.addLayout(close_layout) - - # Initial data load - load_bom() - load_where_used() - - dialog.exec_() - - def IsActive(self): - return FreeCAD.ActiveDocument is not None - - -# --------------------------------------------------------------------------- -# Silo Mode toggle - swap Ctrl+O/S/N between standard and Silo commands -# --------------------------------------------------------------------------- - -# Stored original shortcuts so they can be restored on toggle-off -_original_shortcuts: Dict[str, Any] = {} - - -def _swap_shortcuts(mapping, enable_silo): - """Swap keyboard shortcuts between standard and Silo commands. - - mapping: list of (std_cmd, silo_cmd, shortcut) tuples - enable_silo: True to assign shortcuts to Silo commands, False to restore. - """ - from PySide import QtGui - - mw = FreeCADGui.getMainWindow() - if mw is None: - return - - for std_cmd, silo_cmd, shortcut in mapping: - if enable_silo: - # Save and clear the standard command's shortcut - std_action = mw.findChild(QtGui.QAction, std_cmd) - if std_action: - _original_shortcuts[std_cmd] = std_action.shortcut().toString() - std_action.setShortcut("") - # Assign the shortcut to the Silo command - silo_action = mw.findChild(QtGui.QAction, silo_cmd) - if silo_action: - silo_action.setShortcut(shortcut) - else: - # Clear the Silo command's shortcut - silo_action = mw.findChild(QtGui.QAction, silo_cmd) - if silo_action: - silo_action.setShortcut("") - # Restore the standard command's original shortcut - std_action = mw.findChild(QtGui.QAction, std_cmd) - if std_action and std_cmd in _original_shortcuts: - std_action.setShortcut(_original_shortcuts.pop(std_cmd)) - - -_SHORTCUT_MAP = [ - ("Std_Open", "Silo_Open", "Ctrl+O"), - ("Std_Save", "Silo_Save", "Ctrl+S"), - ("Std_New", "Silo_New", "Ctrl+N"), -] - - -class Silo_ToggleMode: - """Toggle between standard file operations and Silo equivalents.""" - - def GetResources(self): - return { - "MenuText": "Silo Mode", - "ToolTip": ( - "Toggle between standard file operations and Silo equivalents.\n" - "When ON: Ctrl+O/S/N use Silo Open/Save/New.\n" - "When OFF: Standard FreeCAD file operations." - ), - "Pixmap": _icon("silo"), - "Checkable": True, - } - - def Activated(self, checked): - param = FreeCAD.ParamGet(_PREF_GROUP) - if checked: - _swap_shortcuts(_SHORTCUT_MAP, enable_silo=True) - param.SetBool("SiloMode", True) - FreeCAD.Console.PrintMessage("Silo mode enabled\n") - else: - _swap_shortcuts(_SHORTCUT_MAP, enable_silo=False) - param.SetBool("SiloMode", False) - FreeCAD.Console.PrintMessage("Silo mode disabled\n") - - def IsActive(self): - return True - - -# --------------------------------------------------------------------------- -# SSE live-update listener -# --------------------------------------------------------------------------- - - -class SiloEventListener(QtCore.QThread): - """Background thread that listens to Server-Sent Events from the Silo API. - - Emits Qt signals when items are updated or new revisions are created. - Degrades gracefully if the server does not support the ``/api/events`` - endpoint. - """ - - item_updated = QtCore.Signal(str) # part_number - revision_created = QtCore.Signal(str, int) # part_number, revision - connection_status = QtCore.Signal(str) # "connected" / "disconnected" / "unsupported" - - _MAX_FAST_RETRIES = 3 - _FAST_RETRY_SECS = 5 - _SLOW_RETRY_SECS = 30 - - def __init__(self, parent=None): - super().__init__(parent) - self._stop_flag = False - self._response = None - - # -- public API --------------------------------------------------------- - - def stop(self): - self._stop_flag = True - # Close the socket so readline() unblocks immediately - try: - if self._response is not None: - self._response.close() - except Exception: - pass - self.wait(5000) - - # -- thread entry ------------------------------------------------------- - - def run(self): - retries = 0 - while not self._stop_flag: - try: - self._listen() - # _listen returns normally only on clean EOF / stop - if self._stop_flag: - return - retries += 1 - except _SSEUnsupported: - self.connection_status.emit("unsupported") - return - except Exception: - retries += 1 - - self.connection_status.emit("disconnected") - - if retries <= self._MAX_FAST_RETRIES: - delay = self._FAST_RETRY_SECS - else: - delay = self._SLOW_RETRY_SECS - - # Interruptible sleep - for _ in range(delay): - if self._stop_flag: - return - self.msleep(1000) - - # -- SSE stream reader -------------------------------------------------- - - def _listen(self): - url = f"{_get_api_url().rstrip('/')}/api/events" - headers = {"Accept": "text/event-stream"} - headers.update(_get_auth_headers()) - req = urllib.request.Request(url, headers=headers, method="GET") - - try: - self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90) - except urllib.error.HTTPError as e: - if e.code in (404, 501): - raise _SSEUnsupported() - raise - except urllib.error.URLError: - raise - - self.connection_status.emit("connected") - - event_type = "" - data_buf = "" - - for raw_line in self._response: - if self._stop_flag: - return - - line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") - - if line == "": - # Blank line = dispatch event - if data_buf: - self._dispatch(event_type or "message", data_buf.strip()) - event_type = "" - data_buf = "" - elif line.startswith("event:"): - event_type = line[6:].strip() - elif line.startswith("data:"): - data_buf += line[5:].strip() + "\n" - # Ignore comments (lines starting with ':') and other fields - - def _dispatch(self, event_type, data): - try: - payload = json.loads(data) - except (json.JSONDecodeError, ValueError): - return - - pn = payload.get("part_number", "") - if not pn: - return - - if event_type in ("item_updated", "message"): - self.item_updated.emit(pn) - elif event_type == "revision_created": - rev = payload.get("revision", 0) - self.revision_created.emit(pn, int(rev)) - - -class _SSEUnsupported(Exception): - """Raised when the server does not support the SSE endpoint.""" - - -# --------------------------------------------------------------------------- -# Auth dock widget -# --------------------------------------------------------------------------- - - -class SiloAuthDockWidget: - """Content widget for the Silo Database Auth dock panel.""" - - def __init__(self): - from PySide import QtCore, QtGui - - self.widget = QtGui.QWidget() - self._event_listener = None - self._build_ui() - self._refresh_status() - - self._timer = QtCore.QTimer(self.widget) - self._timer.timeout.connect(self._refresh_status) - self._timer.start(30000) - - # -- UI construction ---------------------------------------------------- - - def _build_ui(self): - from PySide import QtCore, QtGui - - layout = QtGui.QVBoxLayout(self.widget) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(6) - - # Status row - status_row = QtGui.QHBoxLayout() - status_row.setSpacing(6) - self._status_dot = QtGui.QLabel("\u2b24") - self._status_dot.setFixedWidth(16) - self._status_dot.setAlignment(QtCore.Qt.AlignCenter) - self._status_label = QtGui.QLabel("Checking...") - status_row.addWidget(self._status_dot) - status_row.addWidget(self._status_label) - status_row.addStretch() - layout.addLayout(status_row) - - # User row - user_row = QtGui.QHBoxLayout() - user_row.setSpacing(6) - user_lbl = QtGui.QLabel("User:") - user_lbl.setStyleSheet("color: #888;") - self._user_label = QtGui.QLabel("(not logged in)") - user_row.addWidget(user_lbl) - user_row.addWidget(self._user_label) - user_row.addStretch() - layout.addLayout(user_row) - - # Role row - role_row = QtGui.QHBoxLayout() - role_row.setSpacing(6) - role_lbl = QtGui.QLabel("Role:") - role_lbl.setStyleSheet("color: #888;") - self._role_label = QtGui.QLabel("") - self._role_label.setStyleSheet("font-size: 11px;") - role_row.addWidget(role_lbl) - role_row.addWidget(self._role_label) - role_row.addStretch() - layout.addLayout(role_row) - - layout.addSpacing(4) - - # URL row (compact display) - url_row = QtGui.QHBoxLayout() - url_row.setSpacing(6) - url_lbl = QtGui.QLabel("URL:") - url_lbl.setStyleSheet("color: #888;") - self._url_label = QtGui.QLabel("") - self._url_label.setStyleSheet("font-size: 11px;") - self._url_label.setWordWrap(True) - url_row.addWidget(url_lbl) - url_row.addWidget(self._url_label, 1) - layout.addLayout(url_row) - - layout.addSpacing(4) - - # Live updates row - sse_row = QtGui.QHBoxLayout() - sse_row.setSpacing(6) - sse_lbl = QtGui.QLabel("Live:") - sse_lbl.setStyleSheet("color: #888;") - self._sse_label = QtGui.QLabel("") - self._sse_label.setStyleSheet("font-size: 11px;") - sse_row.addWidget(sse_lbl) - sse_row.addWidget(self._sse_label) - sse_row.addStretch() - layout.addLayout(sse_row) - - layout.addSpacing(4) - - # Buttons - btn_row = QtGui.QHBoxLayout() - btn_row.setSpacing(6) - - self._login_btn = QtGui.QPushButton("Login") - self._login_btn.clicked.connect(self._on_login_clicked) - btn_row.addWidget(self._login_btn) - - settings_btn = QtGui.QToolButton() - settings_btn.setText("\u2699") - settings_btn.setToolTip("Silo Settings") - settings_btn.setFixedSize(28, 28) - settings_btn.clicked.connect(self._on_settings_clicked) - btn_row.addStretch() - btn_row.addWidget(settings_btn) - - layout.addLayout(btn_row) - layout.addStretch() - - # -- Status refresh ----------------------------------------------------- - - def _refresh_status(self): - from PySide import QtGui - - # Update URL display - self._url_label.setText(_get_api_url()) - - has_token = _client.is_authenticated() - username = _client.auth_username() - role = _client.auth_role() - source = _client.auth_source() - - # Check server connectivity - try: - reachable, msg = _client.check_connection() - except Exception: - reachable = False - - # If reachable and we have a token, validate it against the server - authed = False - if reachable and has_token: - user = _client.get_current_user() - if user and user.get("username"): - authed = True - username = user["username"] - role = user.get("role", "") - source = user.get("auth_source", "") - _save_auth_info(username=username, role=role, source=source) - - if authed: - self._user_label.setText(username) - role_text = role or "" - if source: - role_text += f" ({source})" if role_text else source - self._role_label.setText(role_text) - else: - self._user_label.setText("(not logged in)") - self._role_label.setText("") - - # Update button state - try: - self._login_btn.clicked.disconnect() - except RuntimeError: - pass - - if reachable and authed: - self._status_dot.setStyleSheet("color: #4CAF50; font-size: 10px;") - self._status_label.setText("Connected") - self._login_btn.setText("Logout") - self._login_btn.clicked.connect(self._on_logout_clicked) - elif reachable and has_token and not authed: - # Token exists but is invalid/expired - self._status_dot.setStyleSheet("color: #FF9800; font-size: 10px;") - self._status_label.setText("Token invalid") - self._login_btn.setText("Login") - self._login_btn.clicked.connect(self._on_login_clicked) - elif reachable and not has_token: - self._status_dot.setStyleSheet("color: #FFC107; font-size: 10px;") - self._status_label.setText("Connected (no auth)") - self._login_btn.setText("Login") - self._login_btn.clicked.connect(self._on_login_clicked) - else: - self._status_dot.setStyleSheet("color: #F44336; font-size: 10px;") - self._status_label.setText("Disconnected") - self._login_btn.setText("Login") - self._login_btn.clicked.connect(self._on_login_clicked) - - # Manage SSE listener based on auth state - self._sync_event_listener(authed) - - # -- SSE listener management -------------------------------------------- - - def _sync_event_listener(self, authed): - """Start or stop the SSE listener depending on authentication state.""" - if authed: - if self._event_listener is None or not self._event_listener.isRunning(): - self._event_listener = SiloEventListener() - self._event_listener.item_updated.connect(self._on_remote_change) - self._event_listener.revision_created.connect(self._on_remote_revision) - self._event_listener.connection_status.connect(self._on_sse_status) - self._event_listener.start() - else: - if self._event_listener is not None and self._event_listener.isRunning(): - self._event_listener.stop() - self._sse_label.setText("") - - def _on_sse_status(self, status): - if status == "connected": - self._sse_label.setText("Listening") - self._sse_label.setStyleSheet("font-size: 11px; color: #4CAF50;") - elif status == "disconnected": - self._sse_label.setText("Reconnecting...") - self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;") - elif status == "unsupported": - self._sse_label.setText("Not available") - self._sse_label.setStyleSheet("font-size: 11px; color: #888;") - - def _on_remote_change(self, part_number): - FreeCAD.Console.PrintMessage(f"Silo: Part {part_number} updated on server\n") - mw = FreeCADGui.getMainWindow() - if mw is not None: - mw.statusBar().showMessage(f"Silo: {part_number} updated on server", 5000) - self._refresh_activity_panel() - - def _on_remote_revision(self, part_number, revision): - FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n") - mw = FreeCADGui.getMainWindow() - if mw is not None: - mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000) - self._refresh_activity_panel() - - def _refresh_activity_panel(self): - """Refresh the Database Activity panel if it exists.""" - from PySide import QtWidgets - - mw = FreeCADGui.getMainWindow() - if mw is None: - return - panel = mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseActivity") - if panel is None: - return - activity_list = panel.findChild(QtWidgets.QListWidget) - if activity_list is None: - return - activity_list.clear() - try: - items = _client.list_items() - if isinstance(items, list): - for item in items[:20]: - pn = item.get("part_number", "") - desc = item.get("description", "") - updated = item.get("updated_at", "") - if updated: - updated = updated[:10] - activity_list.addItem(f"{pn} - {desc} - {updated}") - if activity_list.count() == 0: - activity_list.addItem("(No items in database)") - except Exception: - activity_list.addItem("(Unable to refresh activity)") - - # -- Actions ------------------------------------------------------------ - - def _on_login_clicked(self): - self._show_login_dialog() - - def _on_logout_clicked(self): - _client.logout() - FreeCAD.Console.PrintMessage("Silo: Logged out\n") - self._refresh_status() - - def _on_settings_clicked(self): - FreeCADGui.runCommand("Silo_Settings") - # Refresh after settings may have changed - self._refresh_status() - - def _show_login_dialog(self): - from PySide import QtCore, QtGui - - dialog = QtGui.QDialog(self.widget) - dialog.setWindowTitle("Silo Login") - dialog.setMinimumWidth(380) - - layout = QtGui.QVBoxLayout(dialog) - - # Server info - server_label = QtGui.QLabel(f"Server: {_get_api_url()}") - server_label.setStyleSheet("color: #888; font-size: 11px;") - layout.addWidget(server_label) - - layout.addSpacing(4) - - info_label = QtGui.QLabel( - "Enter your credentials to create a persistent API token. " - "Supports local accounts and LDAP (FreeIPA)." - ) - info_label.setWordWrap(True) - info_label.setStyleSheet("color: #888; font-size: 11px;") - layout.addWidget(info_label) - - layout.addSpacing(8) - - # Username - user_label = QtGui.QLabel("Username:") - layout.addWidget(user_label) - user_input = QtGui.QLineEdit() - user_input.setPlaceholderText("Username") - last_user = _get_auth_username() - if last_user: - user_input.setText(last_user) - layout.addWidget(user_input) - - layout.addSpacing(4) - - # Password - pass_label = QtGui.QLabel("Password:") - layout.addWidget(pass_label) - pass_input = QtGui.QLineEdit() - pass_input.setEchoMode(QtGui.QLineEdit.Password) - pass_input.setPlaceholderText("Password") - layout.addWidget(pass_input) - - layout.addSpacing(4) - - # Error / status label - status_label = QtGui.QLabel("") - status_label.setWordWrap(True) - status_label.setVisible(False) - layout.addWidget(status_label) - - layout.addSpacing(8) - - # Buttons - btn_layout = QtGui.QHBoxLayout() - login_btn = QtGui.QPushButton("Login") - cancel_btn = QtGui.QPushButton("Cancel") - btn_layout.addStretch() - btn_layout.addWidget(login_btn) - btn_layout.addWidget(cancel_btn) - layout.addLayout(btn_layout) - - def on_login(): - username = user_input.text().strip() - password = pass_input.text() - if not username or not password: - status_label.setText("Username and password are required.") - status_label.setStyleSheet("color: #F44336;") - status_label.setVisible(True) - return - # Disable inputs during login - login_btn.setEnabled(False) - status_label.setText("Logging in...") - status_label.setStyleSheet("color: #888;") - status_label.setVisible(True) - # Process events so the user sees the status update - from PySide.QtWidgets import QApplication - - QApplication.processEvents() - try: - result = _client.login(username, password) - role = result.get("role", "") - source = result.get("auth_source", "") - msg = f"Silo: Logged in as {username}" - if role: - msg += f" ({role})" - if source: - msg += f" via {source}" - FreeCAD.Console.PrintMessage(msg + "\n") - dialog.accept() - except RuntimeError as e: - status_label.setText(str(e)) - status_label.setStyleSheet("color: #F44336;") - status_label.setVisible(True) - login_btn.setEnabled(True) - - login_btn.clicked.connect(on_login) - cancel_btn.clicked.connect(dialog.reject) - pass_input.returnPressed.connect(on_login) - user_input.returnPressed.connect(lambda: pass_input.setFocus()) - - dialog.exec_() - self._refresh_status() - - -class Silo_Auth: - """Show the Silo authentication panel.""" - - def GetResources(self): - return { - "MenuText": "Authentication", - "ToolTip": "Show Silo authentication status and login", - "Pixmap": _icon("auth"), - } - - def Activated(self): - from PySide import QtGui - - mw = FreeCADGui.getMainWindow() - if mw is None: - return - panel = mw.findChild(QtGui.QDockWidget, "SiloDatabaseAuth") - if panel: - panel.show() - panel.raise_() - - def IsActive(self): - return True - - -# Register commands -FreeCADGui.addCommand("Silo_Open", Silo_Open()) -FreeCADGui.addCommand("Silo_New", Silo_New()) -FreeCADGui.addCommand("Silo_Save", Silo_Save()) -FreeCADGui.addCommand("Silo_Commit", Silo_Commit()) -FreeCADGui.addCommand("Silo_Pull", Silo_Pull()) -FreeCADGui.addCommand("Silo_Push", Silo_Push()) -FreeCADGui.addCommand("Silo_Info", Silo_Info()) -FreeCADGui.addCommand("Silo_BOM", Silo_BOM()) -FreeCADGui.addCommand("Silo_TagProjects", Silo_TagProjects()) -FreeCADGui.addCommand("Silo_Rollback", Silo_Rollback()) -FreeCADGui.addCommand("Silo_SetStatus", Silo_SetStatus()) -FreeCADGui.addCommand("Silo_Settings", Silo_Settings()) -FreeCADGui.addCommand("Silo_ToggleMode", Silo_ToggleMode()) -FreeCADGui.addCommand("Silo_Auth", Silo_Auth()) diff --git a/pkg/freecad/silo_origin.py b/pkg/freecad/silo_origin.py deleted file mode 100644 index d78600c..0000000 --- a/pkg/freecad/silo_origin.py +++ /dev/null @@ -1,584 +0,0 @@ -"""Silo origin adapter for FreeCAD Origin system. - -This module provides the SiloOrigin class that implements the FileOrigin -interface, allowing Silo to be used as a document origin in the unified -origin system introduced in Issue #9. - -The SiloOrigin wraps existing Silo commands and SiloSync functionality, -delegating operations to the established Silo infrastructure while -providing the standardized origin interface. -""" - -import FreeCAD -import FreeCADGui - -from .silo_commands import ( - _client, - _sync, - get_tracked_object, - set_silo_properties, - find_file_by_part_number, - collect_document_properties, -) - - -class SiloOrigin: - """FileOrigin implementation for Silo PLM. - - This class adapts Silo functionality to the FileOrigin interface, - enabling Silo to be used as a document origin in the unified system. - - Key behaviors: - - Documents are always stored locally (hybrid local-remote model) - - Database tracks metadata, part numbers, and revision history - - MinIO stores revision snapshots for sync/backup - - Identity is tracked by UUID (SiloItemId), displayed as part number - """ - - def __init__(self, origin_id="silo", nickname="Silo"): - """Initialize SiloOrigin. - - Args: - origin_id: Unique identifier for this origin instance - nickname: Short display name for UI elements - """ - self._id = origin_id - self._nickname = nickname - - # ========================================================================= - # Identity Methods - # ========================================================================= - - def id(self) -> str: - """Return unique identifier for this origin.""" - return self._id - - def name(self) -> str: - """Return display name for UI.""" - return "Kindred Silo" - - def nickname(self) -> str: - """Return short nickname for compact UI elements.""" - return self._nickname - - def icon(self) -> str: - """Return icon name for BitmapFactory.""" - return "silo" - - def type(self) -> int: - """Return origin type (OriginType.PLM = 1).""" - return 1 - - # ========================================================================= - # Workflow Characteristics - # ========================================================================= - - def tracksExternally(self) -> bool: - """Return True - Silo tracks documents in database.""" - return True - - def requiresAuthentication(self) -> bool: - """Return True - Silo requires user authentication.""" - return True - - # ========================================================================= - # Capabilities - # ========================================================================= - - def supportsRevisions(self) -> bool: - """Return True - Silo supports revision history.""" - return True - - def supportsBOM(self) -> bool: - """Return True - Silo supports Bill of Materials.""" - return True - - def supportsPartNumbers(self) -> bool: - """Return True - Silo assigns part numbers from schema.""" - return True - - def supportsAssemblies(self) -> bool: - """Return True - Silo supports assembly documents.""" - return True - - # ========================================================================= - # Connection State - # ========================================================================= - - def connectionState(self) -> int: - """Return connection state enum value. - - Returns: - 0 = Disconnected - 1 = Connecting - 2 = Connected - 3 = Error - """ - if not _client.is_authenticated(): - return 0 # Disconnected - - try: - ok, _ = _client.check_connection() - return 2 if ok else 3 # Connected or Error - except Exception: - return 3 # Error - - def connect(self) -> bool: - """Trigger authentication if needed. - - Shows the Silo authentication dialog if not already authenticated. - - Returns: - True if authenticated after this call - """ - if _client.is_authenticated(): - return True - - # Show auth dialog via existing Silo_Auth command - try: - cmd = FreeCADGui.Command.get("Silo_Auth") - if cmd: - cmd.Activated() - return _client.is_authenticated() - except Exception as e: - FreeCAD.Console.PrintError(f"Silo connect failed: {e}\n") - return False - - def disconnect(self): - """Log out of Silo.""" - _client.logout() - - # ========================================================================= - # Document Identity - # ========================================================================= - - def documentIdentity(self, doc) -> str: - """Return UUID (SiloItemId) as primary identity. - - The UUID is the immutable tracking key for the document in the - database. Falls back to part number if UUID not yet assigned. - - Args: - doc: FreeCAD App.Document - - Returns: - UUID string, or part number as fallback, or empty string - """ - if not doc: - return "" - - obj = get_tracked_object(doc) - if not obj: - return "" - - # Prefer UUID (SiloItemId) - if hasattr(obj, "SiloItemId") and obj.SiloItemId: - return obj.SiloItemId - - # Fallback to part number - if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber: - return obj.SiloPartNumber - - return "" - - def documentDisplayId(self, doc) -> str: - """Return part number for display. - - The part number is the human-readable identifier shown in the UI. - - Args: - doc: FreeCAD App.Document - - Returns: - Part number string or empty string - """ - if not doc: - return "" - - obj = get_tracked_object(doc) - if obj and hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber: - return obj.SiloPartNumber - - return "" - - def ownsDocument(self, doc) -> bool: - """Check if document is tracked by Silo. - - A document is owned by Silo if it has a tracked object with - SiloItemId or SiloPartNumber property set. - - Args: - doc: FreeCAD App.Document - - Returns: - True if Silo owns this document - """ - if not doc: - return False - - obj = get_tracked_object(doc) - if not obj: - return False - - # Check for SiloItemId (preferred) or SiloPartNumber - if hasattr(obj, "SiloItemId") and obj.SiloItemId: - return True - if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber: - return True - - return False - - # ========================================================================= - # Property Sync - # ========================================================================= - - def syncProperties(self, doc) -> bool: - """Sync document properties to database. - - Pushes syncable properties from the FreeCAD document to the - Silo database. - - Args: - doc: FreeCAD App.Document - - Returns: - True if sync succeeded - """ - if not doc: - return False - - obj = get_tracked_object(doc) - if not obj or not hasattr(obj, "SiloPartNumber"): - return False - - try: - # Collect syncable properties - updates = {} - if hasattr(obj, "SiloDescription") and obj.SiloDescription: - updates["description"] = obj.SiloDescription - - if updates: - _client.update_item(obj.SiloPartNumber, **updates) - - return True - except Exception as e: - FreeCAD.Console.PrintError(f"Silo property sync failed: {e}\n") - return False - - # ========================================================================= - # Core Operations - # ========================================================================= - - def newDocument(self, name: str = ""): - """Create new document via Silo part creation form. - - Delegates to the existing Silo_New command which: - 1. Shows part creation dialog with category selection - 2. Generates part number from schema - 3. Creates document with Silo properties - - Args: - name: Optional document name (not used, Silo assigns name) - - Returns: - Created App.Document or None - """ - try: - cmd = FreeCADGui.Command.get("Silo_New") - if cmd: - cmd.Activated() - return FreeCAD.ActiveDocument - except Exception as e: - FreeCAD.Console.PrintError(f"Silo new document failed: {e}\n") - return None - - def openDocument(self, identity: str): - """Open document by UUID or part number. - - If identity is empty, shows the Silo search dialog. - Otherwise, finds the local file or downloads from Silo. - - Args: - identity: UUID or part number, or empty for search dialog - - Returns: - Opened App.Document or None - """ - if not identity: - # No identity - show search dialog - try: - cmd = FreeCADGui.Command.get("Silo_Open") - if cmd: - cmd.Activated() - return FreeCAD.ActiveDocument - except Exception as e: - FreeCAD.Console.PrintError(f"Silo open failed: {e}\n") - return None - - # Try to find existing local file by part number - # (UUID lookup would require API enhancement) - local_path = find_file_by_part_number(identity) - if local_path and local_path.exists(): - return FreeCAD.openDocument(str(local_path)) - - # Download from Silo - try: - doc = _sync.open_item(identity) - return doc - except Exception as e: - FreeCAD.Console.PrintError(f"Silo open item failed: {e}\n") - return None - - def openDocumentInteractive(self): - """Open document interactively via Silo search dialog. - - Shows the Silo_Open dialog for searching and selecting - a document to open. - - Returns: - Opened App.Document or None - """ - try: - cmd = FreeCADGui.Command.get("Silo_Open") - if cmd: - cmd.Activated() - return FreeCAD.ActiveDocument - except Exception as e: - FreeCAD.Console.PrintError(f"Silo open failed: {e}\n") - return None - - def saveDocument(self, doc) -> bool: - """Save document and sync to Silo. - - Saves the document locally to the canonical path and uploads - to Silo for sync. - - Args: - doc: FreeCAD App.Document - - Returns: - True if save succeeded - """ - if not doc: - return False - - obj = get_tracked_object(doc) - if not obj: - # Not a Silo document - just save locally - if doc.FileName: - doc.save() - return True - return False - - try: - # Save to canonical path - file_path = _sync.save_to_canonical_path(doc) - if not file_path: - FreeCAD.Console.PrintError("Failed to save to canonical path\n") - return False - - # Upload to Silo - properties = collect_document_properties(doc) - _client._upload_file(obj.SiloPartNumber, str(file_path), properties, comment="") - - # Clear modified flag - doc.Modified = False - - return True - except Exception as e: - FreeCAD.Console.PrintError(f"Silo save failed: {e}\n") - return False - - def saveDocumentAs(self, doc, newIdentity: str) -> bool: - """Save with new identity - triggers migration or copy workflow. - - For local documents: Triggers migration to Silo (new item creation) - For Silo documents: Would trigger copy workflow (not yet implemented) - - Args: - doc: FreeCAD App.Document - newIdentity: New identity (currently unused) - - Returns: - True if operation succeeded - """ - if not doc: - return False - - obj = get_tracked_object(doc) - - if not obj: - # Local document being migrated to Silo - # Trigger new item creation form - result = self.newDocument() - return result is not None - - # Already a Silo document - copy workflow - # TODO: Issue #17 will implement copy workflow - FreeCAD.Console.PrintWarning( - "Silo copy workflow not yet implemented. Use Silo_New to create a new item.\n" - ) - return False - - def saveDocumentAsInteractive(self, doc) -> bool: - """Save document interactively with new identity. - - For Silo, this triggers the new item creation form which allows - the user to select category and create a new part number. - - Args: - doc: FreeCAD App.Document - - Returns: - True if operation succeeded - """ - if not doc: - return False - - # For Silo, "Save As" means creating a new item - # Trigger the new item creation form - result = self.newDocument() - return result is not None - - # ========================================================================= - # Extended Operations - # ========================================================================= - - def commitDocument(self, doc) -> bool: - """Commit with revision comment. - - Delegates to Silo_Commit command. - - Args: - doc: FreeCAD App.Document - - Returns: - True if command was executed - """ - try: - cmd = FreeCADGui.Command.get("Silo_Commit") - if cmd: - cmd.Activated() - return True - except Exception as e: - FreeCAD.Console.PrintError(f"Silo commit failed: {e}\n") - return False - - def pullDocument(self, doc) -> bool: - """Pull latest from Silo. - - Delegates to Silo_Pull command. - - Args: - doc: FreeCAD App.Document - - Returns: - True if command was executed - """ - try: - cmd = FreeCADGui.Command.get("Silo_Pull") - if cmd: - cmd.Activated() - return True - except Exception as e: - FreeCAD.Console.PrintError(f"Silo pull failed: {e}\n") - return False - - def pushDocument(self, doc) -> bool: - """Push changes to Silo. - - Delegates to Silo_Push command. - - Args: - doc: FreeCAD App.Document - - Returns: - True if command was executed - """ - try: - cmd = FreeCADGui.Command.get("Silo_Push") - if cmd: - cmd.Activated() - return True - except Exception as e: - FreeCAD.Console.PrintError(f"Silo push failed: {e}\n") - return False - - def showInfo(self, doc): - """Show document info dialog. - - Delegates to Silo_Info command. - - Args: - doc: FreeCAD App.Document - """ - try: - cmd = FreeCADGui.Command.get("Silo_Info") - if cmd: - cmd.Activated() - except Exception as e: - FreeCAD.Console.PrintError(f"Silo info failed: {e}\n") - - def showBOM(self, doc): - """Show BOM dialog. - - Delegates to Silo_BOM command. - - Args: - doc: FreeCAD App.Document - """ - try: - cmd = FreeCADGui.Command.get("Silo_BOM") - if cmd: - cmd.Activated() - except Exception as e: - FreeCAD.Console.PrintError(f"Silo BOM failed: {e}\n") - - -# ============================================================================= -# Module-level functions -# ============================================================================= - -# Global instance -_silo_origin = None - - -def get_silo_origin(): - """Get or create the global SiloOrigin instance. - - Returns: - SiloOrigin instance - """ - global _silo_origin - if _silo_origin is None: - _silo_origin = SiloOrigin() - return _silo_origin - - -def register_silo_origin(): - """Register SiloOrigin with FreeCADGui. - - This should be called during workbench initialization to make - Silo available as a file origin. - """ - origin = get_silo_origin() - try: - FreeCADGui.addOrigin(origin) - FreeCAD.Console.PrintLog("Registered Silo origin\n") - except Exception as e: - FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n") - - -def unregister_silo_origin(): - """Unregister SiloOrigin from FreeCADGui. - - This should be called during workbench cleanup if needed. - """ - global _silo_origin - if _silo_origin: - try: - FreeCADGui.removeOrigin(_silo_origin) - FreeCAD.Console.PrintLog("Unregistered Silo origin\n") - except Exception as e: - FreeCAD.Console.PrintWarning(f"Could not unregister Silo origin: {e}\n") - _silo_origin = None