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