Compare commits

...

1 Commits

Author SHA1 Message Date
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
5 changed files with 586 additions and 3 deletions

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:
@@ -4016,6 +4102,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 +4466,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())

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()