Files
silo-mod/freecad/templates.py
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

168 lines
5.4 KiB
Python

"""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