initial: FreeCAD Silo workbench (extracted from silo monorepo)

FreeCAD workbench for Silo PLM integration. Uses shared silo-client
package (submodule) for API communication.

Changes from monorepo version:
- SiloClient class removed, imported from silo_client package
- FreeCADSiloSettings adapter wraps FreeCAD.ParamGet() preferences
- Init.py adds silo-client to sys.path at startup
- All command classes and UI unchanged
This commit is contained in:
Zoe Forbes
2026-02-06 11:14:44 -06:00
commit bf0b84310b
20 changed files with 3909 additions and 0 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "silo-client"]
path = silo-client
url = https://git.kindred-systems.com/kindred/silo-client.git

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kindred Systems LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

57
Makefile Normal file
View File

@@ -0,0 +1,57 @@
.PHONY: install-freecad install-freecad-flatpak install-freecad-native uninstall-freecad help
# Detect FreeCAD Mod directory (Flatpak or native)
FREECAD_MOD_DIR_FLATPAK := $(HOME)/.var/app/org.freecad.FreeCAD/data/FreeCAD/Mod
FREECAD_MOD_DIR_NATIVE := $(HOME)/.local/share/FreeCAD/Mod
FREECAD_MOD_DIR_LEGACY := $(HOME)/.FreeCAD/Mod
# Install FreeCAD workbench (auto-detect Flatpak or native)
install-freecad:
@if [ -d "$(HOME)/.var/app/org.freecad.FreeCAD" ]; then \
echo "Detected Flatpak FreeCAD (org.freecad.FreeCAD)"; \
mkdir -p $(FREECAD_MOD_DIR_FLATPAK); \
rm -f $(FREECAD_MOD_DIR_FLATPAK)/Silo; \
ln -sf $(PWD)/freecad $(FREECAD_MOD_DIR_FLATPAK)/Silo; \
echo "Installed to $(FREECAD_MOD_DIR_FLATPAK)/Silo"; \
else \
echo "Using native FreeCAD installation"; \
mkdir -p $(FREECAD_MOD_DIR_NATIVE); \
mkdir -p $(FREECAD_MOD_DIR_LEGACY); \
rm -f $(FREECAD_MOD_DIR_NATIVE)/Silo; \
rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo; \
ln -sf $(PWD)/freecad $(FREECAD_MOD_DIR_NATIVE)/Silo; \
ln -sf $(PWD)/freecad $(FREECAD_MOD_DIR_LEGACY)/Silo; \
echo "Installed to $(FREECAD_MOD_DIR_NATIVE)/Silo"; \
fi
@echo ""
@echo "Restart FreeCAD to load the Silo workbench"
install-freecad-flatpak:
mkdir -p $(FREECAD_MOD_DIR_FLATPAK)
rm -f $(FREECAD_MOD_DIR_FLATPAK)/Silo
ln -sf $(PWD)/freecad $(FREECAD_MOD_DIR_FLATPAK)/Silo
@echo "Installed to $(FREECAD_MOD_DIR_FLATPAK)/Silo"
@echo "Restart FreeCAD to load the Silo workbench"
install-freecad-native:
mkdir -p $(FREECAD_MOD_DIR_NATIVE)
mkdir -p $(FREECAD_MOD_DIR_LEGACY)
rm -f $(FREECAD_MOD_DIR_NATIVE)/Silo
rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo
ln -sf $(PWD)/freecad $(FREECAD_MOD_DIR_NATIVE)/Silo
ln -sf $(PWD)/freecad $(FREECAD_MOD_DIR_LEGACY)/Silo
@echo "Installed to $(FREECAD_MOD_DIR_NATIVE)/Silo"
uninstall-freecad:
rm -f $(FREECAD_MOD_DIR_FLATPAK)/Silo
rm -f $(FREECAD_MOD_DIR_NATIVE)/Silo
rm -f $(FREECAD_MOD_DIR_LEGACY)/Silo
@echo "Uninstalled Silo workbench"
help:
@echo "Silo FreeCAD Workbench Makefile"
@echo ""
@echo " install-freecad Install workbench (auto-detect Flatpak/native)"
@echo " install-freecad-flatpak Install for Flatpak FreeCAD"
@echo " install-freecad-native Install for native FreeCAD"
@echo " uninstall-freecad Remove workbench symlinks"

35
README.md Normal file
View File

@@ -0,0 +1,35 @@
# silo-mod
FreeCAD workbench for the Silo parts database. Provides item management, revision control, BOM editing, and file synchronization within Kindred Create.
## Structure
```
silo-mod/
├── silo-client/ [submodule] shared Python API client
├── freecad/ FreeCAD workbench package
│ ├── Init.py Console initialization (adds silo-client to sys.path)
│ ├── InitGui.py Workbench registration
│ ├── silo_commands.py 14 commands + SiloSync + auth dock widget
│ ├── silo_origin.py FileOrigin adapter for unified origin system
│ ├── package.xml Workbench metadata
│ └── resources/icons/ SVG icons (Catppuccin Mocha palette)
├── Makefile Install/uninstall targets
└── LICENSE
```
## Installation
For standalone use (outside Kindred Create):
```bash
git clone --recurse-submodules https://git.kindred-systems.com/kindred/silo-mod.git
cd silo-mod
make install-freecad
```
Within Kindred Create, this repo is included as a submodule at `mods/silo/` and loaded automatically by `src/Mod/Create/Init.py`.
## License
MIT

15
freecad/Init.py Normal file
View File

@@ -0,0 +1,15 @@
"""Silo FreeCAD Workbench - Console initialization.
This file is loaded when FreeCAD starts (even in console mode).
The GUI-specific initialization is in InitGui.py.
"""
import os
import sys
# Add the shared silo-client package to sys.path so that
# ``import silo_client`` works from silo_commands.py.
_mod_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_client_dir = os.path.join(_mod_dir, "silo-client")
if os.path.isdir(_client_dir) and _client_dir not in sys.path:
sys.path.insert(0, _client_dir)

102
freecad/InitGui.py Normal file
View File

@@ -0,0 +1,102 @@
"""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 = """<h3>Welcome to Kindred Silo!</h3>
<p>For the best experience, set up these keyboard shortcuts:</p>
<table style="margin: 10px 0;">
<tr><td><b>Ctrl+O</b></td><td> - </td><td>Silo_Open (Search & Open)</td></tr>
<tr><td><b>Ctrl+N</b></td><td> - </td><td>Silo_New (Register new item)</td></tr>
<tr><td><b>Ctrl+S</b></td><td> - </td><td>Silo_Save (Save & upload)</td></tr>
<tr><td><b>Ctrl+Shift+S</b></td><td> - </td><td>Silo_Commit (Save with comment)</td></tr>
</table>
<p><b>To set shortcuts:</b> Tools > Customize > Keyboard</p>
<p style="color: #888;">This message appears once.</p>"""
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")

15
freecad/package.xml Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>Kindred Silo</name>
<description>Item database and part management workbench for Kindred Create</description>
<version>0.1.0</version>
<maintainer email="info@kindredsystems.io">Kindred Systems</maintainer>
<license file="LICENSE">MIT</license>
<url type="repository">https://github.com/kindredsystems/silo</url>
<content>
<workbench>
<classname>SiloWorkbench</classname>
<subdirectory>./</subdirectory>
</workbench>
</content>
</package>

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Padlock body -->
<rect x="5" y="11" width="14" height="10" rx="2" fill="#313244" stroke="#cba6f7"/>
<!-- Padlock shackle -->
<path d="M8 11V7a4 4 0 0 1 8 0v4" fill="none" stroke="#89dceb"/>
<!-- Keyhole -->
<circle cx="12" cy="16" r="1.5" fill="#89dceb" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Outer box -->
<rect x="3" y="3" width="18" height="18" rx="2" fill="#313244"/>
<!-- List lines (BOM rows) -->
<line x1="8" y1="8" x2="18" y2="8" stroke="#89dceb" stroke-width="1.5"/>
<line x1="8" y1="12" x2="18" y2="12" stroke="#89dceb" stroke-width="1.5"/>
<line x1="8" y1="16" x2="18" y2="16" stroke="#89dceb" stroke-width="1.5"/>
<!-- Hierarchy dots -->
<circle cx="6" cy="8" r="1" fill="#cba6f7"/>
<circle cx="6" cy="12" r="1" fill="#cba6f7"/>
<circle cx="6" cy="16" r="1" fill="#cba6f7"/>
</svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Git commit style -->
<circle cx="12" cy="12" r="4" fill="#313244" stroke="#a6e3a1"/>
<line x1="12" y1="2" x2="12" y2="8" stroke="#cba6f7"/>
<line x1="12" y1="16" x2="12" y2="22" stroke="#cba6f7"/>
<!-- Checkmark inside -->
<polyline points="9.5 12 11 13.5 14.5 10" stroke="#a6e3a1" stroke-width="1.5" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 493 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Info circle -->
<circle cx="12" cy="12" r="10" fill="#313244"/>
<line x1="12" y1="16" x2="12" y2="12" stroke="#89dceb" stroke-width="2"/>
<circle cx="12" cy="8" r="0.5" fill="#89dceb" stroke="#89dceb"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Document with plus -->
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="#313244"/>
<polyline points="14 2 14 8 20 8" fill="#45475a" stroke="#cba6f7"/>
<!-- Plus sign -->
<line x1="12" y1="11" x2="12" y2="17" stroke="#a6e3a1" stroke-width="2"/>
<line x1="9" y1="14" x2="15" y2="14" stroke="#a6e3a1" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Folder open icon -->
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="#313244"/>
<path d="M2 10h20" stroke="#6c7086"/>
<!-- Search magnifier -->
<circle cx="17" cy="15" r="3" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.5"/>
<line x1="19.5" y1="17.5" x2="22" y2="20" stroke="#a6e3a1" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 529 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Cloud -->
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" fill="#313244"/>
<!-- Download arrow -->
<path d="M12 13v5m0 0l-2-2m2 2l2-2" stroke="#89b4fa" stroke-width="2"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="#89b4fa" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Cloud -->
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" fill="#313244"/>
<!-- Upload arrow -->
<path d="M12 18v-5m0 0l-2 2m2-2l2 2" stroke="#a6e3a1" stroke-width="2"/>
<line x1="12" y1="13" x2="12" y2="9" stroke="#a6e3a1" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Floppy disk -->
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" fill="#313244"/>
<polyline points="17 21 17 13 7 13 7 21" fill="#45475a" stroke="#cba6f7"/>
<polyline points="7 3 7 8 15 8" fill="#45475a" stroke="#6c7086"/>
<!-- Upload arrow -->
<path d="M12 17v-4m0 0l-2 2m2-2l2 2" stroke="#a6e3a1" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

View File

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- Silo icon - grain silo with database/sync symbolism -->
<!-- Uses Catppuccin Mocha colors -->
<!-- Silo body (cylindrical tower) -->
<path d="M16 20 L16 52 Q16 56 32 56 Q48 56 48 52 L48 20"
fill="#313244" stroke="#cba6f7" stroke-width="2"/>
<!-- Silo dome/roof -->
<ellipse cx="32" cy="20" rx="16" ry="6" fill="#45475a" stroke="#cba6f7" stroke-width="2"/>
<path d="M24 14 Q32 4 40 14" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round"/>
<line x1="32" y1="6" x2="32" y2="14" stroke="#cba6f7" stroke-width="2" stroke-linecap="round"/>
<!-- Horizontal bands (like database rows / silo rings) -->
<ellipse cx="32" cy="28" rx="15" ry="4" fill="none" stroke="#6c7086" stroke-width="1.5"/>
<ellipse cx="32" cy="36" rx="15" ry="4" fill="none" stroke="#6c7086" stroke-width="1.5"/>
<ellipse cx="32" cy="44" rx="15" ry="4" fill="none" stroke="#6c7086" stroke-width="1.5"/>
<!-- Base ellipse -->
<ellipse cx="32" cy="52" rx="16" ry="4" fill="none" stroke="#cba6f7" stroke-width="2"/>
<!-- Sync arrows (circular) - represents upload/download -->
<g transform="translate(44, 8)">
<circle cx="8" cy="8" r="7" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.5"/>
<path d="M5 6 L8 3 L11 6 M8 3 L8 10" stroke="#a6e3a1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 10 L8 13 L5 10 M8 13 L8 10" stroke="#a6e3a1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

2976
freecad/silo_commands.py Normal file

File diff suppressed because it is too large Load Diff

584
freecad/silo_origin.py Normal file
View File

@@ -0,0 +1,584 @@
"""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

1
silo-client Submodule

Submodule silo-client added at a6ac3d4d06