feat(create): silo tree foundation for .kc files (#37)
All checks were successful
Build and Test / build (pull_request) Successful in 29m13s

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
This commit is contained in:
forbes
2026-02-18 16:30:42 -06:00
parent deb425db44
commit 65f24b23eb
7 changed files with 484 additions and 0 deletions

View File

@@ -22,6 +22,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

View File

@@ -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
# ---------------------------------------------------------------------------
@@ -122,6 +132,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)

View File

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

View File

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

View File

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

229
src/Mod/Create/silo_tree.py Normal file
View File

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

View File

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