From bab396105704dbc701ab64da9f530ee9eec92a9f Mon Sep 17 00:00:00 2001 From: forbes Date: Wed, 18 Feb 2026 16:30:42 -0600 Subject: [PATCH] feat(create): silo tree foundation for .kc files (#37) Add document tree infrastructure that creates Silo metadata nodes when a .kc file is opened. Nodes appear under a "Silo" group and represent the silo/ ZIP directory entries (manifest, metadata, history, etc.). New files: - silo_objects.py: SiloViewerObject proxy with Transient properties - silo_viewproviders.py: SiloViewerViewProvider with icon stubs - silo_tree.py: SiloTreeBuilder with conditional node creation - silo_document.py: SiloDocumentObserver singleton + registration Modified files: - kc_format.py: pre_reinject hook system for silo/ entry mutation - InitGui.py: 600ms timer registration for document observer - CMakeLists.txt: install list for 4 new Python files Closes #37 --- src/Mod/Create/CMakeLists.txt | 10 ++ src/Mod/Create/InitGui.py | 11 ++ src/Mod/Create/kc_format.py | 20 +++ src/Mod/Create/silo_document.py | 85 ++++++++++ src/Mod/Create/silo_objects.py | 58 +++++++ src/Mod/Create/silo_tree.py | 229 +++++++++++++++++++++++++++ src/Mod/Create/silo_viewproviders.py | 77 +++++++++ 7 files changed, 490 insertions(+) create mode 100644 src/Mod/Create/silo_document.py create mode 100644 src/Mod/Create/silo_objects.py create mode 100644 src/Mod/Create/silo_tree.py create mode 100644 src/Mod/Create/silo_viewproviders.py diff --git a/src/Mod/Create/CMakeLists.txt b/src/Mod/Create/CMakeLists.txt index af2bcab4f3..768b29a725 100644 --- a/src/Mod/Create/CMakeLists.txt +++ b/src/Mod/Create/CMakeLists.txt @@ -1,6 +1,12 @@ # Kindred Create core module # Handles auto-loading of ztools and Silo addons +# C++ module targets +add_subdirectory(App) +if(BUILD_GUI) + add_subdirectory(Gui) +endif(BUILD_GUI) + # Generate version.py from template with Kindred Create version configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/version.py.in @@ -15,6 +21,10 @@ install( InitGui.py addon_loader.py kc_format.py + silo_document.py + silo_objects.py + silo_tree.py + silo_viewproviders.py update_checker.py ${CMAKE_CURRENT_BINARY_DIR}/version.py DESTINATION diff --git a/src/Mod/Create/InitGui.py b/src/Mod/Create/InitGui.py index 8a56b33f33..89b4a497bc 100644 --- a/src/Mod/Create/InitGui.py +++ b/src/Mod/Create/InitGui.py @@ -24,6 +24,16 @@ def _register_kc_format(): FreeCAD.Console.PrintLog(f"Create: kc_format registration skipped: {e}\n") +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"Create: silo_document registration skipped: {e}\n") + + # --------------------------------------------------------------------------- # Silo integration enhancements # --------------------------------------------------------------------------- @@ -144,6 +154,7 @@ try: from PySide.QtCore import QTimer QTimer.singleShot(500, _register_kc_format) + QTimer.singleShot(600, _register_silo_document_observer) QTimer.singleShot(1500, _register_silo_origin) QTimer.singleShot(2000, _setup_silo_auth_panel) QTimer.singleShot(3000, _check_silo_first_start) diff --git a/src/Mod/Create/kc_format.py b/src/Mod/Create/kc_format.py index 71e5ae68d6..17953f2dbd 100644 --- a/src/Mod/Create/kc_format.py +++ b/src/Mod/Create/kc_format.py @@ -18,6 +18,19 @@ import FreeCAD # Cache: filepath -> {entry_name: bytes} _silo_cache = {} +# Pre-reinject hooks: called with (doc, filename, entries) before ZIP write. +_pre_reinject_hooks = [] + + +def register_pre_reinject(callback): + """Register a callback invoked before silo/ entries are written to ZIP. + + Signature: callback(doc, filename, entries) -> None + ``entries`` is a dict {entry_name: bytes} or None. Mutate in place. + """ + _pre_reinject_hooks.append(callback) + + KC_VERSION = "1.0" @@ -62,6 +75,13 @@ class _KcFormatObserver: _silo_cache.pop(filename, None) return entries = _silo_cache.pop(filename, None) + for _hook in _pre_reinject_hooks: + try: + _hook(doc, filename, entries) + except Exception as exc: + FreeCAD.Console.PrintWarning( + f"kc_format: pre_reinject hook failed: {exc}\n" + ) try: with zipfile.ZipFile(filename, "a") as zf: existing = set(zf.namelist()) diff --git a/src/Mod/Create/silo_document.py b/src/Mod/Create/silo_document.py new file mode 100644 index 0000000000..cb6fee72ab --- /dev/null +++ b/src/Mod/Create/silo_document.py @@ -0,0 +1,85 @@ +""" +silo_document.py - Document observer that builds the Silo metadata tree +when a .kc file is opened in FreeCAD. + +Hooks slotCreatedDocument (primary) and slotActivateDocument (fallback) +to detect .kc opens, then defers tree building to the next event loop +tick via QTimer.singleShot(0, ...) so the document is fully loaded. +""" + +import FreeCAD + +_observer = None + + +def _on_kc_restored(doc): + """Deferred callback: build the Silo tree after document is fully loaded.""" + try: + filename = doc.FileName + if not filename: + return + from silo_tree import SiloTreeBuilder + + contents = SiloTreeBuilder.read_silo_directory(filename) + if contents: + SiloTreeBuilder.build_tree(doc, contents) + except Exception as exc: + FreeCAD.Console.PrintWarning( + f"silo_document: failed to build silo tree for {doc.Name!r}: {exc}\n" + ) + + +class SiloDocumentObserver: + """Singleton observer that triggers Silo tree creation for .kc files.""" + + def slotCreatedDocument(self, doc): + """Called when a document is created or opened.""" + try: + filename = doc.FileName + if not filename or not filename.lower().endswith(".kc"): + return + from PySide.QtCore import QTimer + + QTimer.singleShot(0, lambda: _on_kc_restored(doc)) + except Exception as exc: + FreeCAD.Console.PrintWarning( + f"silo_document: slotCreatedDocument error: {exc}\n" + ) + + def slotActivateDocument(self, doc): + """Fallback for documents opened before observer registration.""" + try: + filename = doc.FileName + if not filename or not filename.lower().endswith(".kc"): + return + if doc.getObject("Silo") is not None: + return + from PySide.QtCore import QTimer + + QTimer.singleShot(0, lambda: _on_kc_restored(doc)) + except Exception as exc: + FreeCAD.Console.PrintWarning( + f"silo_document: slotActivateDocument error: {exc}\n" + ) + + def slotDeletedDocument(self, doc): + """Placeholder for future cleanup.""" + pass + + +def register(): + """Register the singleton observer. Safe to call multiple times.""" + global _observer + if _observer is not None: + return + + _observer = SiloDocumentObserver() + FreeCAD.addDocumentObserver(_observer) + FreeCAD.Console.PrintLog("silo_document: observer registered\n") + + # Bootstrap: handle documents already open before registration. + try: + for doc in FreeCAD.listDocuments().values(): + _observer.slotActivateDocument(doc) + except Exception as exc: + FreeCAD.Console.PrintWarning(f"silo_document: bootstrap scan failed: {exc}\n") diff --git a/src/Mod/Create/silo_objects.py b/src/Mod/Create/silo_objects.py new file mode 100644 index 0000000000..10693958fc --- /dev/null +++ b/src/Mod/Create/silo_objects.py @@ -0,0 +1,58 @@ +""" +silo_objects.py - FreeCAD FeaturePython proxy for Silo tree leaf nodes. + +Each silo/ ZIP entry in a .kc file gets one SiloViewerObject in the +FreeCAD document tree. All properties are Transient so they are never +persisted in Document.xml. +""" + +import FreeCAD + + +class SiloViewerObject: + """Proxy for App::FeaturePython silo viewer nodes. + + Properties (all Transient): + SiloPath - ZIP entry path, e.g. "silo/manifest.json" + ContentType - "json", "yaml", or "py" + RawContent - decoded UTF-8 content of the entry + """ + + def __init__(self, obj): + obj.Proxy = self + + obj.addProperty( + "App::PropertyString", + "SiloPath", + "Silo", + "ZIP entry path of this silo item", + ) + obj.addProperty( + "App::PropertyString", + "ContentType", + "Silo", + "Content type of this silo item", + ) + obj.addProperty( + "App::PropertyString", + "RawContent", + "Silo", + "Raw text content of this silo entry", + ) + + obj.setPropertyStatus("SiloPath", "Transient") + obj.setPropertyStatus("ContentType", "Transient") + obj.setPropertyStatus("RawContent", "Transient") + + obj.setEditorMode("SiloPath", 1) # read-only in property panel + obj.setEditorMode("ContentType", 1) + obj.setEditorMode("RawContent", 2) # hidden in property panel + + def execute(self, obj): + pass + + def __getstate__(self): + return None + + def __setstate__(self, state): + pass diff --git a/src/Mod/Create/silo_tree.py b/src/Mod/Create/silo_tree.py new file mode 100644 index 0000000000..63f48d09f1 --- /dev/null +++ b/src/Mod/Create/silo_tree.py @@ -0,0 +1,229 @@ +""" +silo_tree.py - Builds the Silo metadata tree in the FreeCAD document. + +Reads silo/ entries from a .kc ZIP and creates a conditional hierarchy +of App::FeaturePython and App::DocumentObjectGroup objects in the +document tree. + +Tree structure: + Silo (App::DocumentObjectGroup) + +-- Manifest (always present) + +-- Metadata (if metadata.json is non-empty) + +-- History (if history.json has revisions) + +-- Approvals (if approvals.json has eco field) + +-- Dependencies (if dependencies.json has links) + +-- Jobs (group, if silo/jobs/ has YAML files) + | +-- default.yaml + +-- Macros (group, if silo/macros/ has .py files) + +-- on_save +""" + +import json +import zipfile + +import FreeCAD + +_SILO_GROUP_NAME = "Silo" + +# Top-level silo/ entries with their object names, labels, and +# optional JSON field checks for conditional creation. +_KNOWN_ENTRIES = [ + # (zip_name, object_name, label, json_check) + # json_check is None (always create) or (field_name, check_fn) + ("silo/manifest.json", "SiloManifest", "Manifest", None), + ("silo/metadata.json", "SiloMetadata", "Metadata", None), + ( + "silo/history.json", + "SiloHistory", + "History", + ("revisions", lambda v: isinstance(v, list) and len(v) > 0), + ), + ( + "silo/approvals.json", + "SiloApprovals", + "Approvals", + ("eco", lambda v: v is not None), + ), + ( + "silo/dependencies.json", + "SiloDependencies", + "Dependencies", + ("links", lambda v: isinstance(v, list) and len(v) > 0), + ), +] + + +def _content_type(entry_name): + """Determine content type from ZIP entry name.""" + if entry_name.endswith(".json"): + return "json" + if entry_name.endswith((".yaml", ".yml")): + return "yaml" + if entry_name.endswith(".py"): + return "py" + return "text" + + +def _decode(data): + """Decode bytes to UTF-8 string, returning '' on failure.""" + try: + return data.decode("utf-8") + except Exception: + return "" + + +def _should_create(data, json_check): + """Check whether a conditional node should be created.""" + if json_check is None: + return True + field_name, check_fn = json_check + try: + parsed = json.loads(data) + return check_fn(parsed.get(field_name)) + except Exception: + return False + + +def _create_leaf(doc, parent, entry_name, data, obj_name, label): + """Create one App::FeaturePython leaf and add it to parent group.""" + from silo_objects import SiloViewerObject + from silo_viewproviders import SiloViewerViewProvider + + obj = doc.addObject("App::FeaturePython", obj_name) + SiloViewerObject(obj) + + obj.SiloPath = entry_name + obj.ContentType = _content_type(entry_name) + obj.RawContent = _decode(data) + obj.Label = label + + try: + import FreeCADGui # noqa: F401 + + if obj.ViewObject is not None: + SiloViewerViewProvider(obj.ViewObject) + except ImportError: + pass # headless mode + + parent.addObject(obj) + return obj + + +class SiloTreeBuilder: + """Reads silo/ from a .kc ZIP and builds the document tree.""" + + @staticmethod + def read_silo_directory(filename): + """Read silo/ entries from a .kc ZIP. + + Returns dict {entry_name: bytes}, e.g. {"silo/manifest.json": b"..."}. + Returns {} on failure. + """ + entries = {} + try: + with zipfile.ZipFile(filename, "r") as zf: + for name in zf.namelist(): + if name.startswith("silo/") and not name.endswith("/"): + entries[name] = zf.read(name) + except Exception as exc: + FreeCAD.Console.PrintWarning( + f"silo_tree: could not read silo/ from {filename!r}: {exc}\n" + ) + return entries + + @staticmethod + def build_tree(doc, silo_contents): + """Create the Silo group hierarchy in doc from silo/ entries.""" + if not silo_contents: + return + + SiloTreeBuilder.remove_silo_tree(doc) + + root = doc.addObject("App::DocumentObjectGroup", _SILO_GROUP_NAME) + root.Label = "Silo" + + # Top-level known entries (conditional creation) + for zip_name, obj_name, label, json_check in _KNOWN_ENTRIES: + if zip_name not in silo_contents: + continue + data = silo_contents[zip_name] + if not _should_create(data, json_check): + continue + _create_leaf(doc, root, zip_name, data, obj_name, label) + + # Jobs subgroup + job_entries = { + k: v + for k, v in silo_contents.items() + if k.startswith("silo/jobs/") and not k.endswith("/") + } + if job_entries: + jobs_group = doc.addObject("App::DocumentObjectGroup", "SiloJobs") + jobs_group.Label = "Jobs" + root.addObject(jobs_group) + for entry_name in sorted(job_entries): + basename = entry_name.split("/")[-1] + safe_name = "SiloJob_" + basename.replace(".", "_").replace("-", "_") + _create_leaf( + doc, + jobs_group, + entry_name, + job_entries[entry_name], + safe_name, + basename, + ) + + # Macros subgroup + macro_entries = { + k: v + for k, v in silo_contents.items() + if k.startswith("silo/macros/") and not k.endswith("/") + } + if macro_entries: + macros_group = doc.addObject("App::DocumentObjectGroup", "SiloMacros") + macros_group.Label = "Macros" + root.addObject(macros_group) + for entry_name in sorted(macro_entries): + basename = entry_name.split("/")[-1] + label = basename[:-3] if basename.endswith(".py") else basename + safe_name = "SiloMacro_" + basename.replace(".", "_").replace("-", "_") + _create_leaf( + doc, + macros_group, + entry_name, + macro_entries[entry_name], + safe_name, + label, + ) + + doc.recompute() + FreeCAD.Console.PrintLog( + f"silo_tree: built tree with {len(silo_contents)} entries in {doc.Name!r}\n" + ) + + @staticmethod + def remove_silo_tree(doc): + """Remove the Silo group and all descendants. Safe if absent.""" + root = doc.getObject(_SILO_GROUP_NAME) + if root is None: + return + + names = [] + + def _collect(obj): + if obj.Name in names: + return + names.append(obj.Name) + if hasattr(obj, "OutList"): + for child in obj.OutList: + _collect(child) + + _collect(root) + + for name in reversed(names): + try: + doc.removeObject(name) + except Exception as exc: + FreeCAD.Console.PrintWarning( + f"silo_tree: could not remove {name!r}: {exc}\n" + ) diff --git a/src/Mod/Create/silo_viewproviders.py b/src/Mod/Create/silo_viewproviders.py new file mode 100644 index 0000000000..d42e0ff414 --- /dev/null +++ b/src/Mod/Create/silo_viewproviders.py @@ -0,0 +1,77 @@ +""" +silo_viewproviders.py - ViewProvider proxy for Silo tree leaf nodes. + +Controls tree icon, double-click behavior, and context menu for +SiloViewerObject nodes in the document tree. +""" + +import os + +# Icon directory — Phase 6 will add SVGs here. +_ICON_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "resources", + "icons", +) + +# Map silo/ paths to icon basenames (without .svg extension). +_SILO_PATH_ICONS = { + "silo/manifest.json": "silo-manifest", + "silo/metadata.json": "silo-metadata", + "silo/history.json": "silo-history", + "silo/approvals.json": "silo-approvals", + "silo/dependencies.json": "silo-dependencies", +} + +# Prefix-based fallbacks for subdirectory entries. +_SILO_PREFIX_ICONS = { + "silo/jobs/": "silo-job", + "silo/macros/": "silo-macro", +} + + +def _icon_for_path(silo_path): + """Return absolute icon path for a silo/ entry, or '' if not found.""" + name = _SILO_PATH_ICONS.get(silo_path) + if name is None: + for prefix, icon_name in _SILO_PREFIX_ICONS.items(): + if silo_path.startswith(prefix): + name = icon_name + break + if name is None: + return "" + path = os.path.join(_ICON_DIR, f"{name}.svg") + return path if os.path.exists(path) else "" + + +class SiloViewerViewProvider: + """ViewProvider proxy for SiloViewerObject leaf nodes.""" + + def __init__(self, vobj): + vobj.Proxy = self + self.Object = vobj.Object + + def attach(self, vobj): + """Store back-reference; called on document restore.""" + self.Object = vobj.Object + + def getIcon(self): + """Return icon path based on SiloPath; '' uses FreeCAD default.""" + try: + return _icon_for_path(self.Object.SiloPath) + except Exception: + return "" + + def doubleClicked(self, vobj): + """Phase 1: no action on double-click.""" + return False + + def setupContextMenu(self, vobj, menu): + """Phase 1: no context menu items.""" + pass + + def __getstate__(self): + return None + + def __setstate__(self, state): + pass -- 2.49.1