Compare commits

...

6 Commits

Author SHA1 Message Date
c5f00219fa feat(silo): async save queue — background upload with coalescing (#392)
Local save is synchronous (data safe on disk before returning).
Network upload (file, DAG, BOM) runs in a background QThread.

New files:
- silo_upload_queue.py: UploadTask dataclass + UploadWorker(QThread)
  with coalescing, cancel, shutdown, and Qt signals
- silo_status_widget.py: SyncStatusWidget for status bar feedback

Modified:
- silo_commands.py: _extract_dag_data, _extract_bom_data, _enqueue_upload
  helpers; Silo_Save and Silo_Commit use async enqueue
- silo_origin.py: saveDocument() saves locally then enqueues async upload
- InitGui.py: upload queue init at 700ms, shutdown handler via atexit

Thread safety: worker never accesses App.Document or Qt widgets;
all data pre-extracted on main thread as plain dicts/strings.
Queue uses threading.Lock; signals cross thread boundary via Qt.
2026-03-05 11:08:44 -06:00
forbes
f6222a5181 chore: add .mailmap to normalize git identity 2026-03-03 14:17:26 -06:00
forbes-0023
9187622239 chore: add root package.xml, Init.py, and InitGui.py (#373)
Move package.xml from freecad/ to mod root following the standard
addon layout used by sdk, gears, datums, and solver.

Add root Init.py (sys.path setup for silo-client) and InitGui.py
that consolidates all Silo GUI initialization:
- SiloWorkbench class and registration
- Silo overlay context registration
- Document observer for .kc tree building
- Auth and activity dock panels via kindred_sdk
- First-start settings check
- Start page override
- kindred:// URL handler

This decouples Silo initialization from src/Mod/Create/InitGui.py,
which previously held five Silo-specific deferred setup functions.
2026-03-03 08:19:42 -06:00
63a6da8b6c Merge pull request 'chore(deps): update silo-client to 5dfb567b' (#50) from auto/update-silo-client-5dfb567b into main
Reviewed-on: #50
2026-02-26 19:54:26 +00:00
forbes-0023
a88e104d94 feat(templates): document templating system with Save as Template command
Add a template system that lets users create new items from pre-configured
.kc template files and save existing documents as reusable templates.

Template use (New Item form):
- templates.py: discovery, filtering, TemplateInfo dataclass
- schema_form.py: template combo picker filtered by type/category
- silo_commands.py: SiloSync.create_document_from_template() copies
  template .kc, strips identity, stamps Silo properties

Template creation (Save as Template):
- SaveAsTemplateDialog: captures name, description, item types,
  categories, author, and tags
- Silo_SaveAsTemplate command: copies doc, strips Silo identity,
  injects silo/template.json, optionally uploads to Silo
- Registered in Silo menu via InitGui.py

Template search paths (3-tier, later shadows earlier by name):
1. mods/silo/freecad/templates/ (system)
2. ~/.local/share/FreeCAD/Templates/ (personal, sister to Macro/)
3. ~/projects/templates/ (org-shared)
2026-02-21 09:06:26 -06:00
kindred-bot
7dc157894f chore(deps): update silo-client to 5dfb567b
Upstream: 5dfb567bac
2026-02-19 20:49:11 +00:00
13 changed files with 1347 additions and 43 deletions

7
.mailmap Normal file
View File

@@ -0,0 +1,7 @@
forbes <contact@kindred-systems.com> forbes <joseph.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> forbes <zoe.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> forbes-0023 <joseph.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> forbes-0023 <zoe.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> josephforbes23 <joseph.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> Zoe Forbes <forbes@copernicus-9.kindred.internal>
forbes <contact@kindred-systems.com> admin <admin@kindred-systems.com>

13
Init.py Normal file
View File

@@ -0,0 +1,13 @@
"""Silo addon — console initialization.
Adds the shared silo-client package to sys.path so that
``import silo_client`` works from silo_commands.py and other modules.
"""
import os
import sys
_mod_dir = 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)

314
InitGui.py Normal file
View File

@@ -0,0 +1,314 @@
"""Kindred Silo addon — GUI initialization.
Registers the SiloWorkbench, Silo file origin, overlay context,
dock panels (auth + activity), document observer, and start page override.
"""
import os
import FreeCAD
import FreeCADGui
FreeCAD.Console.PrintMessage("Kindred Silo InitGui.py loading...\n")
# ---------------------------------------------------------------------------
# Workbench
# ---------------------------------------------------------------------------
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):
icon_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"freecad",
"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")
# Silo origin toolbar — shown as an overlay on any context when the
# active document is Silo-tracked. Registered as Unavailable so
# EditingContextResolver controls visibility via the overlay system.
self.silo_toolbar_commands = [
"Silo_Commit",
"Silo_Pull",
"Silo_Push",
"Separator",
"Silo_Info",
"Silo_BOM",
"Silo_Jobs",
]
self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable")
# Silo menu provides admin/management commands.
self.menu_commands = [
"Silo_Info",
"Silo_BOM",
"Silo_Jobs",
"Silo_TagProjects",
"Silo_SetStatus",
"Silo_Rollback",
"Silo_SaveAsTemplate",
"Separator",
"Silo_Settings",
"Silo_Auth",
"Silo_Runners",
"Silo_StartPanel",
"Silo_Diag",
]
self.appendMenu("Silo", self.menu_commands)
def Activated(self):
"""Called when workbench is activated."""
FreeCAD.Console.PrintMessage("Kindred Silo workbench activated\n")
FreeCADGui.runCommand("Silo_StartPanel", 0)
def Deactivated(self):
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
FreeCADGui.addWorkbench(SiloWorkbench())
FreeCAD.Console.PrintMessage("Silo workbench registered\n")
# ---------------------------------------------------------------------------
# Silo overlay context — adds "Silo Origin" toolbar to any active context
# when the current document is Silo-tracked.
# ---------------------------------------------------------------------------
def _register_silo_overlay():
"""Register the Silo overlay after the Silo workbench has initialised."""
def _silo_overlay_match():
"""Return True if the active document is Silo-tracked."""
try:
doc = FreeCAD.ActiveDocument
if not doc:
return False
from silo_origin import get_silo_origin
origin = get_silo_origin()
return origin.ownsDocument(doc)
except Exception:
return False
try:
from kindred_sdk import register_overlay
register_overlay(
"silo", # overlay id
["Silo Origin"], # toolbar names to append
_silo_overlay_match, # match function
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo overlay registration failed: {e}\n")
# ---------------------------------------------------------------------------
# Document observer — builds the Silo metadata tree when .kc files open.
# ---------------------------------------------------------------------------
def _register_silo_document_observer():
"""Register the Silo document observer for .kc tree building."""
try:
import silo_document
silo_document.register()
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: document observer registration skipped: {e}\n")
# ---------------------------------------------------------------------------
# Dock panels — auth and activity widgets via SDK
# ---------------------------------------------------------------------------
def _setup_silo_auth_panel():
"""Dock the Silo authentication panel in the right-hand side panel."""
try:
from kindred_sdk import register_dock_panel
def _factory():
import silo_commands
auth = silo_commands.SiloAuthDockWidget()
# Prevent GC of the auth timer by stashing on the widget
auth.widget._auth = auth
return auth.widget
register_dock_panel("SiloDatabaseAuth", "Database Auth", _factory)
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: auth panel skipped: {e}\n")
def _setup_silo_activity_panel():
"""Show a dock widget with recent Silo database activity."""
try:
from kindred_sdk import register_dock_panel
def _factory():
from PySide import QtWidgets
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
activity_list = QtWidgets.QListWidget()
layout.addWidget(activity_list)
try:
import silo_commands
items = silo_commands._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 connect to Silo database)")
return widget
register_dock_panel("SiloDatabaseActivity", "Database Activity", _factory)
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: activity panel skipped: {e}\n")
# ---------------------------------------------------------------------------
# First-start check
# ---------------------------------------------------------------------------
def _check_silo_first_start():
"""Show Silo settings dialog on first startup if not yet configured."""
try:
param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/KindredSilo")
if not param.GetBool("FirstStartChecked", False):
param.SetBool("FirstStartChecked", True)
if not param.GetString("ApiUrl", ""):
FreeCADGui.runCommand("Silo_Settings")
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: first-start check skipped: {e}\n")
# ---------------------------------------------------------------------------
# Start page override — must happen before the C++ StartLauncher fires
# at ~100ms after GUI init.
# ---------------------------------------------------------------------------
try:
import silo_start
silo_start.register()
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo Start page override failed: {e}\n")
# ---------------------------------------------------------------------------
# Handle kindred:// URLs passed as command-line arguments on cold start.
# ---------------------------------------------------------------------------
def _handle_startup_urls():
"""Process any kindred:// URLs passed as command-line arguments."""
import sys
from silo_commands import handle_kindred_url
for arg in sys.argv[1:]:
if arg.startswith("kindred://"):
handle_kindred_url(arg)
# ---------------------------------------------------------------------------
# Upload queue — background thread for async file uploads
# ---------------------------------------------------------------------------
def _setup_upload_queue():
"""Start the upload queue and register the status bar widget."""
try:
from silo_upload_queue import get_upload_queue
queue = get_upload_queue()
# Status bar widget
try:
from silo_status_widget import SyncStatusWidget
mw = FreeCADGui.getMainWindow()
if mw and mw.statusBar():
widget = SyncStatusWidget(queue, parent=mw.statusBar())
mw.statusBar().addPermanentWidget(widget)
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: status widget skipped: {e}\n")
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: upload queue skipped: {e}\n")
def _shutdown_upload_queue():
"""Drain the upload queue on application exit."""
try:
from silo_upload_queue import shutdown_upload_queue
shutdown_upload_queue()
except Exception:
pass
def _register_shutdown_handler():
"""Connect the upload queue shutdown to application exit."""
try:
import atexit
atexit.register(_shutdown_upload_queue)
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: shutdown handler skipped: {e}\n")
# ---------------------------------------------------------------------------
# Deferred setup — staggered timers for non-blocking startup
# ---------------------------------------------------------------------------
from PySide import QtCore as _QtCore
_QtCore.QTimer.singleShot(500, _handle_startup_urls)
_QtCore.QTimer.singleShot(600, _register_silo_document_observer)
_QtCore.QTimer.singleShot(700, _setup_upload_queue)
_QtCore.QTimer.singleShot(800, _register_shutdown_handler)
_QtCore.QTimer.singleShot(2000, _setup_silo_auth_panel)
_QtCore.QTimer.singleShot(2500, _register_silo_overlay)
_QtCore.QTimer.singleShot(3000, _check_silo_first_start)
_QtCore.QTimer.singleShot(4000, _setup_silo_activity_panel)

View File

@@ -57,6 +57,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
"Silo_TagProjects",
"Silo_SetStatus",
"Silo_Rollback",
"Silo_SaveAsTemplate",
"Separator",
"Silo_Settings",
"Silo_Auth",

View File

@@ -234,9 +234,11 @@ class SchemaFormWidget(QtWidgets.QWidget):
self._prop_groups = [] # list of _CollapsibleGroup to clear on category change
self._categories = {}
self._projects = []
self._templates = [] # List[TemplateInfo]
self._load_schema_data()
self._build_ui()
self._update_template_combo()
# Part number preview debounce timer
self._pn_timer = QtCore.QTimer(self)
@@ -251,7 +253,9 @@ class SchemaFormWidget(QtWidgets.QWidget):
try:
schema = self._client.get_schema()
segments = schema.get("segments", [])
cat_segment = next((s for s in segments if s.get("name") == "category"), None)
cat_segment = next(
(s for s in segments if s.get("name") == "category"), None
)
if cat_segment and cat_segment.get("values"):
self._categories = cat_segment["values"]
except Exception as e:
@@ -262,6 +266,16 @@ class SchemaFormWidget(QtWidgets.QWidget):
except Exception:
self._projects = []
try:
from templates import discover_templates, get_search_paths
self._templates = discover_templates(get_search_paths())
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Schema form: failed to discover templates: {e}\n"
)
self._templates = []
def _fetch_properties(self, category: str) -> dict:
"""Fetch merged property definitions for a category."""
try:
@@ -301,7 +315,9 @@ class SchemaFormWidget(QtWidgets.QWidget):
# Part number preview banner
self._pn_label = QtWidgets.QLabel("Part Number: \u2014")
self._pn_label.setStyleSheet("font-size: 16px; font-weight: bold; padding: 8px;")
self._pn_label.setStyleSheet(
"font-size: 16px; font-weight: bold; padding: 8px;"
)
self._pn_label.setAlignment(QtCore.Qt.AlignCenter)
root.addWidget(self._pn_label)
@@ -323,8 +339,15 @@ class SchemaFormWidget(QtWidgets.QWidget):
self._type_combo = QtWidgets.QComboBox()
for t in _ITEM_TYPES:
self._type_combo.addItem(t.capitalize(), t)
self._type_combo.currentIndexChanged.connect(
lambda _: self._update_template_combo()
)
fl.addRow("Type:", self._type_combo)
self._template_combo = QtWidgets.QComboBox()
self._template_combo.addItem("(No template)", None)
fl.addRow("Template:", self._template_combo)
self._desc_edit = QtWidgets.QLineEdit()
self._desc_edit.setPlaceholderText("Item description")
fl.addRow("Description:", self._desc_edit)
@@ -404,11 +427,25 @@ class SchemaFormWidget(QtWidgets.QWidget):
root.addLayout(btn_layout)
# -- template filtering -------------------------------------------------
def _update_template_combo(self):
"""Repopulate the template combo based on current type and category."""
from templates import filter_templates
self._template_combo.clear()
self._template_combo.addItem("(No template)", None)
item_type = self._type_combo.currentData() or ""
category = self._cat_picker.selected_category() or ""
for t in filter_templates(self._templates, item_type, category):
self._template_combo.addItem(t.name, t.path)
# -- category change ----------------------------------------------------
def _on_category_changed(self, category: str):
"""Rebuild property groups when category selection changes."""
self._create_btn.setEnabled(bool(category))
self._update_template_combo()
# Remove old property groups
for group in self._prop_groups:
@@ -540,6 +577,7 @@ class SchemaFormWidget(QtWidgets.QWidget):
"long_description": long_description,
"projects": selected_projects if selected_projects else None,
"properties": properties if properties else None,
"template_path": self._template_combo.currentData(),
}
def _on_create(self):

View File

@@ -551,6 +551,86 @@ class SiloSync:
return doc
def create_document_from_template(
self, item: Dict[str, Any], template_path: str, save: bool = True
):
"""Create a new document by copying a template .kc and stamping Silo properties.
Falls back to :meth:`create_document_for_item` if *template_path*
is missing or invalid.
"""
import shutil
part_number = item.get("part_number", "")
description = item.get("description", "")
item_type = item.get("item_type", "part")
if not part_number or not os.path.isfile(template_path):
return self.create_document_for_item(item, save=save)
dest_path = get_cad_file_path(part_number, description)
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_path, str(dest_path))
self._clean_template_zip(str(dest_path))
doc = FreeCAD.openDocument(str(dest_path))
if not doc:
return None
# Find the root container (first App::Part or Assembly)
root_obj = None
for obj in doc.Objects:
if obj.TypeId in ("App::Part", "Assembly::AssemblyObject"):
root_obj = obj
break
if root_obj is None and doc.Objects:
root_obj = doc.Objects[0]
if root_obj:
root_obj.Label = part_number
set_silo_properties(
root_obj,
{
"SiloItemId": item.get("id", ""),
"SiloPartNumber": part_number,
"SiloRevision": item.get("current_revision", 1),
"SiloItemType": item_type,
},
)
doc.recompute()
if save:
doc.save()
return doc
@staticmethod
def _clean_template_zip(filepath: str):
"""Strip ``silo/template.json`` and ``silo/manifest.json`` from a copied template.
The manifest is auto-recreated by ``kc_format.py`` on next save.
"""
import zipfile
tmp_path = filepath + ".tmp"
try:
with zipfile.ZipFile(filepath, "r") as zf_in:
with zipfile.ZipFile(tmp_path, "w") as zf_out:
for entry in zf_in.infolist():
if entry.filename in (
"silo/template.json",
"silo/manifest.json",
):
continue
zf_out.writestr(entry, zf_in.read(entry.filename))
os.replace(tmp_path, filepath)
except Exception as exc:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
FreeCAD.Console.PrintWarning(f"Failed to clean template ZIP: {exc}\n")
def open_item(self, part_number: str):
"""Open or create item document."""
existing_path = find_file_by_part_number(part_number)
@@ -742,7 +822,13 @@ class Silo_New:
FreeCAD.ActiveDocument, force_rename=True
)
else:
_sync.create_document_for_item(result, save=True)
template_path = result.get("_form_data", {}).get("template_path")
if template_path:
_sync.create_document_from_template(
result, template_path, save=True
)
else:
_sync.create_document_for_item(result, save=True)
FreeCAD.Console.PrintMessage(f"Created: {part_number}\n")
except Exception as e:
@@ -834,6 +920,67 @@ def _push_bom_after_upload(doc, part_number, revision_number):
FreeCAD.Console.PrintWarning(f"BOM sync failed: {e}\n")
def _extract_dag_data(doc):
"""Pre-extract DAG data on the main thread for async upload.
Returns ``(nodes, edges)`` or ``None`` if extraction fails or
the document has no meaningful DAG.
"""
try:
from dag import extract_dag
nodes, edges = extract_dag(doc)
return (nodes, edges) if nodes else None
except Exception:
return None
def _extract_bom_data(doc):
"""Pre-extract BOM entries on the main thread for async upload.
Returns a list of resolved :class:`bom_sync.BomEntry` objects
(with ``part_number`` populated) or ``None`` if the document is
not an assembly or has no cross-document links.
Resolution calls ``_client.get_item_by_uuid()`` which is a fast
DB lookup. This must run on the main thread because it accesses
``App.Document`` objects to read ``SiloItemId`` properties.
"""
try:
from bom_sync import extract_bom_entries, resolve_entries
entries = extract_bom_entries(doc)
if not entries:
return None
resolved, _unresolved = resolve_entries(entries, _client)
return resolved if resolved else None
except Exception:
return None
def _enqueue_upload(doc, part_number, file_path, properties, comment=""):
"""Capture all upload data on the main thread and enqueue for async upload.
The caller must have already saved the file to *file_path* and
collected *properties* synchronously.
"""
from silo_upload_queue import UploadTask, get_upload_queue
dag_data = _extract_dag_data(doc)
bom_data = _extract_bom_data(doc)
task = UploadTask(
doc_name=doc.Name,
part_number=part_number,
file_path=str(file_path),
properties=properties,
comment=comment,
dag_data=dag_data,
bom_data=bom_data,
)
get_upload_queue().enqueue(task)
class Silo_Save:
"""Save locally and upload to the server."""
@@ -895,22 +1042,8 @@ class Silo_Save:
FreeCAD.Console.PrintMessage(f"Saved: {file_path}\n")
# Try to upload to server
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")
_push_dag_after_upload(doc, part_number, new_rev)
_push_bom_after_upload(doc, part_number, new_rev)
_update_manifest_revision(str(file_path), new_rev)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n")
FreeCAD.Console.PrintMessage("File saved locally but not uploaded.\n")
# Enqueue async upload (non-blocking)
_enqueue_upload(doc, part_number, file_path, properties, comment="Auto-save")
def IsActive(self):
return FreeCAD.ActiveDocument is not None and _server_mode == "normal"
@@ -950,24 +1083,23 @@ class Silo_Commit:
# 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
file_path = _sync.save_to_canonical_path(doc, force_rename=True)
if not file_path:
FreeCAD.Console.PrintError("Could not determine save path\n")
return
result = _client._upload_file(
part_number, str(file_path), properties, comment
)
# Clear modified flag — data is safe on disk
gui_doc = FreeCADGui.getDocument(doc.Name)
if gui_doc and gui_doc.Modified:
try:
gui_doc.Modified = False
except Exception:
pass
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
FreeCAD.Console.PrintMessage(f"Saved: {file_path}\n")
_push_dag_after_upload(doc, part_number, new_rev)
_push_bom_after_upload(doc, part_number, new_rev)
_update_manifest_revision(str(file_path), new_rev)
except Exception as e:
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
# Enqueue async upload (non-blocking)
_enqueue_upload(doc, part_number, file_path, properties, comment=comment)
def IsActive(self):
return FreeCAD.ActiveDocument is not None and _server_mode == "normal"
@@ -4016,6 +4148,218 @@ class Silo_StartPanel:
return True
class SaveAsTemplateDialog:
"""Dialog to capture template metadata for Save as Template."""
_ITEM_TYPES = ["part", "assembly", "consumable", "tool"]
def __init__(self, doc, parent=None):
self._doc = doc
self._parent = parent
def exec_(self):
"""Show dialog. Returns template_info dict or None if cancelled."""
from PySide import QtGui, QtWidgets
dlg = QtWidgets.QDialog(self._parent)
dlg.setWindowTitle("Save as Template")
dlg.setMinimumWidth(420)
layout = QtWidgets.QVBoxLayout(dlg)
form = QtWidgets.QFormLayout()
form.setSpacing(6)
# Pre-populate defaults from document state
default_name = self._doc.Label or Path(self._doc.FileName).stem
obj = get_tracked_object(self._doc)
default_author = _get_auth_username() or os.environ.get("USER", "")
default_item_type = ""
default_category = ""
if obj:
if hasattr(obj, "SiloItemType"):
default_item_type = getattr(obj, "SiloItemType", "")
if hasattr(obj, "SiloPartNumber") and obj.SiloPartNumber:
default_category, _ = _parse_part_number(obj.SiloPartNumber)
# Name (required)
name_edit = QtWidgets.QLineEdit(default_name)
name_edit.setPlaceholderText("Template display name")
form.addRow("Name:", name_edit)
# Description
desc_edit = QtWidgets.QLineEdit()
desc_edit.setPlaceholderText("What this template is for")
form.addRow("Description:", desc_edit)
# Item Types (multi-select)
type_list = QtWidgets.QListWidget()
type_list.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
type_list.setMaximumHeight(80)
for t in self._ITEM_TYPES:
item = QtWidgets.QListWidgetItem(t.capitalize())
item.setData(QtCore.Qt.UserRole, t)
type_list.addItem(item)
if t == default_item_type:
item.setSelected(True)
form.addRow("Item Types:", type_list)
# Categories (comma-separated prefixes)
cat_edit = QtWidgets.QLineEdit(default_category)
cat_edit.setPlaceholderText("e.g. F, M01 (empty = all)")
form.addRow("Categories:", cat_edit)
# Author
author_edit = QtWidgets.QLineEdit(default_author)
form.addRow("Author:", author_edit)
# Tags (comma-separated)
tags_edit = QtWidgets.QLineEdit()
tags_edit.setPlaceholderText("e.g. sheet metal, fabrication")
form.addRow("Tags:", tags_edit)
layout.addLayout(form)
# Buttons
btn_layout = QtWidgets.QHBoxLayout()
btn_layout.addStretch()
cancel_btn = QtWidgets.QPushButton("Cancel")
cancel_btn.clicked.connect(dlg.reject)
btn_layout.addWidget(cancel_btn)
save_btn = QtWidgets.QPushButton("Save Template")
save_btn.setDefault(True)
save_btn.clicked.connect(dlg.accept)
btn_layout.addWidget(save_btn)
layout.addLayout(btn_layout)
if dlg.exec_() != QtWidgets.QDialog.Accepted:
return None
name = name_edit.text().strip()
if not name:
return None
selected_types = []
for i in range(type_list.count()):
item = type_list.item(i)
if item.isSelected():
selected_types.append(item.data(QtCore.Qt.UserRole))
categories = [c.strip() for c in cat_edit.text().split(",") if c.strip()]
tags = [t.strip() for t in tags_edit.text().split(",") if t.strip()]
return {
"template_version": "1.0",
"name": name,
"description": desc_edit.text().strip(),
"item_types": selected_types,
"categories": categories,
"icon": "",
"author": author_edit.text().strip(),
"tags": tags,
}
class Silo_SaveAsTemplate:
"""Save a copy of the current document as a reusable template."""
def GetResources(self):
return {
"MenuText": "Save as Template",
"ToolTip": "Save a copy of this document as a reusable template",
"Pixmap": _icon("new"),
}
def Activated(self):
import shutil
from PySide import QtGui
from templates import get_default_template_dir, inject_template_json
doc = FreeCAD.ActiveDocument
if not doc:
return
if not doc.FileName:
QtGui.QMessageBox.warning(
None,
"Save as Template",
"Please save the document first.",
)
return
# Capture template metadata from user
dialog = SaveAsTemplateDialog(doc)
template_info = dialog.exec_()
if not template_info:
return
# Determine destination
dest_dir = get_default_template_dir()
filename = _sanitize_filename(template_info["name"]) + ".kc"
dest = os.path.join(dest_dir, filename)
# Check for overwrite
if os.path.exists(dest):
reply = QtGui.QMessageBox.question(
None,
"Save as Template",
f"Template '{filename}' already exists. Overwrite?",
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
if reply != QtGui.QMessageBox.Yes:
return
# Save current state, then copy
doc.save()
shutil.copy2(doc.FileName, dest)
# Strip Silo identity and inject template descriptor
_sync._clean_template_zip(dest)
inject_template_json(dest, template_info)
FreeCAD.Console.PrintMessage(f"Template saved: {dest}\n")
# Offer Silo upload if connected
if _server_mode == "normal":
reply = QtGui.QMessageBox.question(
None,
"Save as Template",
"Upload template to Silo for team sharing?",
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
if reply == QtGui.QMessageBox.Yes:
self._upload_to_silo(dest, template_info)
QtGui.QMessageBox.information(
None,
"Save as Template",
f"Template '{template_info['name']}' saved.",
)
@staticmethod
def _upload_to_silo(file_path, template_info):
"""Upload template to Silo as a shared item. Non-blocking on failure."""
try:
schema = _get_schema_name()
result = _client.create_item(
schema, "T00", template_info.get("name", "Template")
)
part_number = result["part_number"]
_client._upload_file(part_number, file_path, {}, "Template upload")
FreeCAD.Console.PrintMessage(
f"Template uploaded to Silo as {part_number}\n"
)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Template upload to Silo failed (saved locally): {e}\n"
)
def IsActive(self):
return FreeCAD.ActiveDocument is not None
class _DiagWorker(QtCore.QThread):
"""Background worker that runs connectivity diagnostics."""
@@ -4168,3 +4512,4 @@ FreeCADGui.addCommand("Silo_StartPanel", Silo_StartPanel())
FreeCADGui.addCommand("Silo_Diag", Silo_Diag())
FreeCADGui.addCommand("Silo_Jobs", Silo_Jobs())
FreeCADGui.addCommand("Silo_Runners", Silo_Runners())
FreeCADGui.addCommand("Silo_SaveAsTemplate", Silo_SaveAsTemplate())

View File

@@ -357,16 +357,17 @@ class SiloOrigin:
return None
def saveDocument(self, doc) -> bool:
"""Save document and sync to Silo.
"""Save document locally and enqueue async upload to Silo.
Saves the document locally to the canonical path and uploads
to Silo for sync.
The local save is synchronous — the user's data is safe on disk
before this method returns. The network upload (file, DAG, BOM)
runs in a background thread via the upload queue.
Args:
doc: FreeCAD App.Document
Returns:
True if save succeeded
True if the local save succeeded
"""
if not doc:
return False
@@ -380,21 +381,25 @@ class SiloOrigin:
return False
try:
# Save to canonical path
# 1. Synchronous: save to disk
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
# 2. Synchronous: collect all data needed for upload
properties = collect_document_properties(doc)
_client._upload_file(obj.SiloPartNumber, str(file_path), properties, comment="")
# Clear modified flag (Modified is on Gui.Document, not App.Document)
# 3. Synchronous: clear modified flag (data is safe on disk)
gui_doc = FreeCADGui.getDocument(doc.Name)
if gui_doc:
gui_doc.Modified = False
# 4. Async: enqueue upload (non-blocking)
from silo_commands import _enqueue_upload
_enqueue_upload(doc, obj.SiloPartNumber, file_path, properties, comment="")
return True
except Exception as e:
import traceback

View File

@@ -0,0 +1,61 @@
"""Status bar widget for the Silo upload queue.
Shows sync progress in the main window status bar:
- Hidden when idle
- "Syncing PN-001..." during upload
- "Synced PN-001 r3" briefly on success
- "Sync failed: PN-001" in red on failure
"""
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QLabel
class SyncStatusWidget(QLabel):
"""Compact status bar indicator for background Silo uploads."""
_FADE_MS = 4000 # how long the success message stays visible
def __init__(self, queue, parent=None):
super().__init__(parent)
self.setVisible(False)
self._queue = queue
self._fade_timer = QTimer(self)
self._fade_timer.setSingleShot(True)
self._fade_timer.timeout.connect(self._hide)
queue.upload_started.connect(self._on_started)
queue.upload_finished.connect(self._on_finished)
queue.upload_failed.connect(self._on_failed)
# -- slots ---------------------------------------------------------------
def _on_started(self, doc_name: str, part_number: str) -> None:
pending = self._queue.pending_count
if pending > 0:
self.setText(f"Syncing {part_number} (+{pending} queued)...")
else:
self.setText(f"Syncing {part_number}...")
self.setStyleSheet("")
self._fade_timer.stop()
self.setVisible(True)
def _on_finished(self, doc_name: str, part_number: str, revision: int) -> None:
pending = self._queue.pending_count
if pending > 0:
self.setText(f"Synced {part_number} r{revision} ({pending} remaining)")
else:
self.setText(f"Synced {part_number} r{revision}")
self.setStyleSheet("")
self._fade_timer.start(self._FADE_MS)
def _on_failed(self, doc_name: str, part_number: str, error: str) -> None:
self.setText(f"Sync failed: {part_number}")
self.setToolTip(error)
self.setStyleSheet("color: #f38ba8;") # Catppuccin Mocha red
self._fade_timer.stop()
self.setVisible(True)
def _hide(self) -> None:
self.setVisible(False)
self.setToolTip("")

View File

@@ -0,0 +1,250 @@
"""Async upload queue for Silo PLM.
Provides a background ``QThread`` that processes file uploads, DAG syncs,
and BOM syncs without blocking the main UI thread. All data required for
an upload is captured on the main thread before enqueuing — the worker
never accesses ``App.Document``, ``Gui.Document``, or any Qt widget.
Typical usage from ``SiloOrigin.saveDocument``::
task = UploadTask(
doc_name=doc.Name,
part_number=obj.SiloPartNumber,
file_path=str(saved_path),
properties=collected_props,
comment="",
dag_data=extracted_dag,
bom_data=extracted_bom,
)
get_upload_queue().enqueue(task)
"""
import collections
import dataclasses
import threading
import time
import FreeCAD
from PySide6.QtCore import QThread, Signal
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclasses.dataclass
class UploadTask:
"""Immutable snapshot of everything needed to upload a document revision.
All fields are plain Python types — no FreeCAD or Qt objects.
"""
doc_name: str
"""FreeCAD document internal name (used for coalescing)."""
part_number: str
"""Silo part number."""
file_path: str
"""Absolute path to the saved ``.kc`` file on disk."""
properties: dict
"""Pre-collected document properties dict."""
comment: str = ""
"""Revision comment (empty for auto-save)."""
dag_data: tuple | None = None
"""Pre-extracted ``(nodes, edges)`` or ``None``."""
bom_data: object | None = None
"""Pre-extracted BOM payload or ``None``."""
timestamp: float = dataclasses.field(default_factory=time.time)
"""Time the task was created (``time.time()``)."""
# ---------------------------------------------------------------------------
# Worker thread
# ---------------------------------------------------------------------------
class UploadWorker(QThread):
"""Background thread that processes :class:`UploadTask` items serially.
Signals are emitted on completion so the main thread can update the UI.
The worker never touches FreeCAD documents or Qt widgets.
"""
upload_started = Signal(str, str) # doc_name, part_number
upload_finished = Signal(str, str, int) # doc_name, part_number, revision
upload_failed = Signal(str, str, str) # doc_name, part_number, error
queue_empty = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._queue: collections.deque[UploadTask] = collections.deque()
self._lock = threading.Lock()
self._event = threading.Event()
self._stop = False
# -- public API (called from main thread) --------------------------------
def enqueue(self, task: UploadTask) -> None:
"""Add a task, coalescing any pending task for the same document."""
with self._lock:
self._queue = collections.deque(t for t in self._queue if t.doc_name != task.doc_name)
self._queue.append(task)
self._event.set()
def cancel(self, doc_name: str) -> None:
"""Remove any pending (not yet started) task for *doc_name*."""
with self._lock:
self._queue = collections.deque(t for t in self._queue if t.doc_name != doc_name)
@property
def pending_count(self) -> int:
"""Number of tasks waiting to be processed."""
with self._lock:
return len(self._queue)
def shutdown(self) -> None:
"""Signal the worker to stop and wait up to 5 s for it to finish."""
self._stop = True
self._event.set()
self.wait(5000)
# -- thread entry point --------------------------------------------------
def run(self) -> None: # noqa: D401 — Qt override
"""Process tasks until :meth:`shutdown` is called."""
from silo_commands import _client, _update_manifest_revision
while not self._stop:
self._event.wait(timeout=1.0)
self._event.clear()
while not self._stop:
task = self._pop_task()
if task is None:
break
self._process(task, _client)
if not self._stop and self.pending_count == 0:
self.queue_empty.emit()
# -- internals -----------------------------------------------------------
def _pop_task(self) -> UploadTask | None:
with self._lock:
return self._queue.popleft() if self._queue else None
def _process(self, task: UploadTask, client) -> None:
self.upload_started.emit(task.doc_name, task.part_number)
try:
result = client._upload_file(
task.part_number, task.file_path, task.properties, task.comment
)
rev = result.get("revision_number", 0)
# DAG sync (non-critical)
if task.dag_data:
try:
nodes, edges = task.dag_data
client.push_dag(task.part_number, rev, nodes, edges)
except Exception as exc:
FreeCAD.Console.PrintWarning(
f"[upload-queue] DAG sync failed for {task.part_number}: {exc}\n"
)
# BOM sync (non-critical)
if task.bom_data:
try:
_push_bom_from_extracted(task.part_number, task.bom_data, client)
except Exception as exc:
FreeCAD.Console.PrintWarning(
f"[upload-queue] BOM sync failed for {task.part_number}: {exc}\n"
)
# Manifest revision update (local file I/O, safe from worker)
try:
from silo_commands import _update_manifest_revision
_update_manifest_revision(task.file_path, rev)
except Exception:
pass
self.upload_finished.emit(task.doc_name, task.part_number, rev)
FreeCAD.Console.PrintMessage(
f"[upload-queue] Uploaded {task.part_number} as revision {rev}\n"
)
except Exception as exc:
self.upload_failed.emit(task.doc_name, task.part_number, str(exc))
FreeCAD.Console.PrintWarning(
f"[upload-queue] Upload failed for {task.part_number}: {exc}\n"
)
def _push_bom_from_extracted(part_number: str, bom_data, client) -> None:
"""Diff and apply pre-extracted BOM entries against the server.
*bom_data* is a list of resolved ``bom_sync.BomEntry`` dataclasses
(with ``part_number`` populated) produced by
:func:`silo_commands._extract_bom_data` on the main thread.
This function fetches the current server BOM, diffs it against the
local entries, and applies adds/quantity updates. No FreeCAD
document access is needed — safe to call from the worker thread.
"""
if not bom_data:
return
from bom_sync import apply_bom_diff, diff_bom
try:
remote = client.get_bom(part_number)
except Exception:
remote = []
diff = diff_bom(bom_data, remote)
result = apply_bom_diff(diff, part_number, client)
parts = []
if result.added_count:
parts.append(f"+{result.added_count} added")
if result.updated_count:
parts.append(f"~{result.updated_count} qty updated")
if parts:
FreeCAD.Console.PrintMessage(
f"[upload-queue] BOM synced for {part_number}: {', '.join(parts)}\n"
)
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_upload_queue: UploadWorker | None = None
def get_upload_queue() -> UploadWorker:
"""Return the global upload queue, creating it on first call."""
global _upload_queue
if _upload_queue is None:
_upload_queue = UploadWorker()
_upload_queue.start()
return _upload_queue
def shutdown_upload_queue() -> None:
"""Stop the global upload queue if running."""
global _upload_queue
if _upload_queue is not None:
count = _upload_queue.pending_count
if count:
FreeCAD.Console.PrintWarning(
f"[upload-queue] Waiting for {count} pending upload(s)...\n"
)
_upload_queue.shutdown()
_upload_queue = None

167
freecad/templates.py Normal file
View File

@@ -0,0 +1,167 @@
"""Template discovery and metadata for Kindred Create .kc templates.
A template is a normal ``.kc`` file that contains a ``silo/template.json``
descriptor inside the ZIP archive. This module scans known directories for
template files, parses their descriptors, and provides filtering helpers
used by the schema form UI.
Search paths (checked in order, later shadows earlier by name):
1. ``{silo_addon}/templates/`` — system templates shipped with the addon
2. ``{userAppData}/Templates/`` — personal templates (sister to Macro/)
3. ``~/projects/templates/`` — org-shared project templates
"""
import json
import os
import shutil
import tempfile
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
@dataclass
class TemplateInfo:
"""Parsed template descriptor from ``silo/template.json``."""
path: str # Absolute path to the .kc file
name: str = ""
description: str = ""
item_types: List[str] = field(default_factory=list)
categories: List[str] = field(default_factory=list)
icon: str = ""
author: str = ""
tags: List[str] = field(default_factory=list)
def read_template_info(kc_path: str) -> Optional[TemplateInfo]:
"""Read ``silo/template.json`` from a ``.kc`` file.
Returns a :class:`TemplateInfo` if the file is a valid template,
or ``None`` if the file does not contain a template descriptor.
"""
try:
with zipfile.ZipFile(kc_path, "r") as zf:
if "silo/template.json" not in zf.namelist():
return None
raw = zf.read("silo/template.json")
data = json.loads(raw)
return TemplateInfo(
path=str(kc_path),
name=data.get("name", Path(kc_path).stem),
description=data.get("description", ""),
item_types=data.get("item_types", []),
categories=data.get("categories", []),
icon=data.get("icon", ""),
author=data.get("author", ""),
tags=data.get("tags", []),
)
except Exception:
return None
def inject_template_json(kc_path: str, template_info: dict) -> bool:
"""Inject ``silo/template.json`` into a ``.kc`` (ZIP) file.
Rewrites the ZIP to avoid duplicate entries. Returns ``True`` on
success, raises on I/O errors.
"""
if not os.path.isfile(kc_path):
return False
payload = json.dumps(template_info, indent=2).encode("utf-8")
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".kc")
os.close(tmp_fd)
try:
with zipfile.ZipFile(kc_path, "r") as zin:
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == "silo/template.json":
continue
zout.writestr(item, zin.read(item.filename))
zout.writestr("silo/template.json", payload)
shutil.move(tmp_path, kc_path)
return True
except Exception:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
def get_default_template_dir() -> str:
"""Return the user templates directory, creating it if needed.
Located at ``{userAppData}/Templates/``, a sister to ``Macro/``.
"""
import FreeCAD
d = os.path.join(FreeCAD.getUserAppDataDir(), "Templates")
os.makedirs(d, exist_ok=True)
return d
def discover_templates(search_paths: List[str]) -> List[TemplateInfo]:
"""Scan search paths for ``.kc`` files containing ``silo/template.json``.
Later paths shadow earlier paths by template name.
"""
by_name = {}
for search_dir in search_paths:
if not os.path.isdir(search_dir):
continue
for filename in sorted(os.listdir(search_dir)):
if not filename.lower().endswith(".kc"):
continue
full_path = os.path.join(search_dir, filename)
info = read_template_info(full_path)
if info is not None:
by_name[info.name] = info
return sorted(by_name.values(), key=lambda t: t.name)
def get_search_paths() -> List[str]:
"""Return the ordered list of template search directories."""
paths = []
# 1. System templates (shipped with the silo addon)
system_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
paths.append(system_dir)
# 2. User app-data templates (personal, sister to Macro/)
try:
import FreeCAD
user_app_templates = os.path.join(FreeCAD.getUserAppDataDir(), "Templates")
paths.append(user_app_templates)
except Exception:
pass
# 3. Shared project templates
try:
from silo_commands import get_projects_dir
user_dir = str(get_projects_dir() / "templates")
paths.append(user_dir)
except Exception:
pass
return paths
def filter_templates(
templates: List[TemplateInfo],
item_type: str = "",
category: str = "",
) -> List[TemplateInfo]:
"""Filter templates by item type and category prefix."""
result = []
for t in templates:
if item_type and t.item_types and item_type not in t.item_types:
continue
if category and t.categories:
if not any(category.startswith(prefix) for prefix in t.categories):
continue
result.append(t)
return result

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Inject silo/template.json into a .kc file to make it a template.
Usage:
python inject_template.py <kc_file> <name> [--type part|assembly] [--description "..."]
Examples:
python inject_template.py part.kc "Part (Generic)" --type part
python inject_template.py sheet-metal-part.kc "Sheet Metal Part" --type part \
--description "Body with SheetMetal base feature and laser-cut job"
python inject_template.py assembly.kc "Assembly" --type assembly
"""
import argparse
import json
import os
import sys
# Allow importing from the parent freecad/ directory when run standalone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from templates import inject_template_json
def main():
parser = argparse.ArgumentParser(
description="Inject template metadata into a .kc file"
)
parser.add_argument("kc_file", help="Path to the .kc file")
parser.add_argument("name", help="Template display name")
parser.add_argument(
"--type",
dest="item_types",
action="append",
default=[],
help="Item type filter (part, assembly). Can be repeated.",
)
parser.add_argument("--description", default="", help="Template description")
parser.add_argument("--icon", default="", help="Icon identifier")
parser.add_argument("--author", default="Kindred Systems", help="Author name")
parser.add_argument(
"--category",
dest="categories",
action="append",
default=[],
help="Category prefix filter. Can be repeated. Empty = all.",
)
parser.add_argument(
"--tag",
dest="tags",
action="append",
default=[],
help="Searchable tags. Can be repeated.",
)
args = parser.parse_args()
if not args.item_types:
args.item_types = ["part"]
template_info = {
"template_version": "1.0",
"name": args.name,
"description": args.description,
"item_types": args.item_types,
"categories": args.categories,
"icon": args.icon,
"author": args.author,
"tags": args.tags,
}
if inject_template_json(args.kc_file, template_info):
print(f"Injected silo/template.json into {args.kc_file}")
print(json.dumps(template_info, indent=2))
else:
sys.exit(1)
if __name__ == "__main__":
main()

25
package.xml Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>silo</name>
<description>PLM workbench for Kindred Create</description>
<version>0.1.0</version>
<maintainer email="development@kindred-systems.com">Kindred Systems</maintainer>
<license file="LICENSE">MIT</license>
<url type="repository">https://git.kindred-systems.com/kindred/silo-mod</url>
<content>
<workbench>
<classname>SiloWorkbench</classname>
<subdirectory>freecad</subdirectory>
</workbench>
</content>
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>60</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
</kindred>
</package>