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