Files
create/src/Mod/Create/silo_tree.py
forbes 65f24b23eb
All checks were successful
Build and Test / build (pull_request) Successful in 29m13s
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
2026-02-18 16:36:29 -06:00

230 lines
7.1 KiB
Python

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