feat(create): metadata editor — editable form with dirty tracking and save-back (#39)
All checks were successful
Build and Test / build (pull_request) Successful in 30m48s

Add SiloMetadataEditor widget that opens in an MDI subwindow when the
user double-clicks the Metadata node in the Silo tree. Supports editing
lifecycle state, tags (add/remove chips), and schema-defined fields with
type-inferred widgets (QCheckBox, QDoubleSpinBox, QLineEdit).

Changes:
- silo_viewers.py: SiloMetadataEditor with dirty tracking, Save/Reset
  buttons, unsaved-changes close guard, and tag chip management
- silo_objects.py: mark_dirty()/is_dirty()/clear_dirty() on proxy
- kc_format.py: fix entries=None before hooks; _metadata_save_hook
  writes dirty RawContent back to silo/ cache on document save

Closes #39
This commit is contained in:
forbes
2026-02-18 17:11:05 -06:00
parent 90728414a9
commit e947822c7a
3 changed files with 330 additions and 2 deletions

View File

@@ -31,6 +31,20 @@ def register_pre_reinject(callback):
_pre_reinject_hooks.append(callback)
def _metadata_save_hook(doc, filename, entries):
"""Write dirty metadata back to the silo/ cache before ZIP write."""
obj = doc.getObject("SiloMetadata")
if obj is None or not hasattr(obj, "Proxy"):
return
proxy = obj.Proxy
if proxy is None or not proxy.is_dirty():
return
entries["silo/metadata.json"] = obj.RawContent.encode("utf-8")
register_pre_reinject(_metadata_save_hook)
KC_VERSION = "1.0"
@@ -75,6 +89,8 @@ class _KcFormatObserver:
_silo_cache.pop(filename, None)
return
entries = _silo_cache.pop(filename, None)
if entries is None:
entries = {}
for _hook in _pre_reinject_hooks:
try:
_hook(doc, filename, entries)

View File

@@ -1,5 +1,5 @@
"""
silo_objects.py - FreeCAD FeaturePython proxy for Silo tree leaf nodes.
silo_objects.py - Create 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
@@ -51,6 +51,18 @@ class SiloViewerObject:
def execute(self, obj):
pass
def mark_dirty(self):
"""Flag this object's content as modified by a viewer widget."""
self._dirty = True
def is_dirty(self):
"""Return True if content has been modified since last save."""
return getattr(self, "_dirty", False)
def clear_dirty(self):
"""Clear the dirty flag after saving."""
self._dirty = False
def __getstate__(self):
return None

View File

@@ -1,11 +1,12 @@
"""
silo_viewers.py — Read-only MDI viewer widgets for Silo tree leaf nodes.
silo_viewers.py — MDI viewer widgets for Silo tree leaf nodes.
Each viewer is a plain QWidget suitable for embedding in an MDI subwindow.
The ``create_viewer_widget`` factory routes a SiloViewerObject to the
appropriate viewer class based on its SiloPath property.
"""
import copy
import json
import FreeCAD
@@ -124,12 +125,311 @@ def _add_row(form, label_text, display_val, raw_val, copyable):
form.addRow(label_text + ":", value_label)
# ---------------------------------------------------------------------------
# Metadata Editor
# ---------------------------------------------------------------------------
_LIFECYCLE_OPTIONS = ["draft", "review", "released", "obsolete"]
class SiloMetadataEditor(QtWidgets.QWidget):
"""Editable form for ``silo/metadata.json`` fields."""
WINDOW_TITLE = "Silo \u2014 Metadata"
def __init__(self, obj, parent=None):
super().__init__(parent)
self.setObjectName(f"SiloViewer_{obj.Name}")
self._obj = obj
self._original_data = {}
self._field_widgets = {} # key -> QWidget
self._tags_layout = None
self._lifecycle_combo = None
self._save_btn = None
self._reset_btn = None
self._schema_name = ""
self._build_ui(obj.RawContent)
# -- layout --------------------------------------------------------------
def _build_ui(self, raw_content):
try:
data = json.loads(raw_content)
except Exception:
data = {}
self._original_data = copy.deepcopy(data)
self._schema_name = data.get("schema", "")
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header: title + lifecycle combo
header = QtWidgets.QHBoxLayout()
title = QtWidgets.QLabel("Part Metadata")
font = title.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
title.setFont(font)
header.addWidget(title)
header.addStretch()
self._lifecycle_combo = QtWidgets.QComboBox()
self._lifecycle_combo.addItems(_LIFECYCLE_OPTIONS)
current_lc = data.get("lifecycle", "draft")
idx = self._lifecycle_combo.findText(current_lc)
if idx >= 0:
self._lifecycle_combo.setCurrentIndex(idx)
self._lifecycle_combo.currentTextChanged.connect(self._on_edited)
header.addWidget(QtWidgets.QLabel("Lifecycle:"))
header.addWidget(self._lifecycle_combo)
outer.addLayout(header)
# Separator
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.HLine)
line.setFrameShadow(QtWidgets.QFrame.Sunken)
outer.addWidget(line)
# Scroll area for form content
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
content = QtWidgets.QWidget()
content_layout = QtWidgets.QVBoxLayout(content)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(12)
# Schema label
schema_text = self._schema_name or "\u2014"
content_layout.addWidget(QtWidgets.QLabel(f"Schema: {schema_text}"))
# Tags row
tags_container = QtWidgets.QWidget()
tags_outer = QtWidgets.QHBoxLayout(tags_container)
tags_outer.setContentsMargins(0, 0, 0, 0)
tags_outer.setSpacing(4)
tags_outer.addWidget(QtWidgets.QLabel("Tags:"))
self._tags_layout = QtWidgets.QHBoxLayout()
self._tags_layout.setContentsMargins(0, 0, 0, 0)
self._tags_layout.setSpacing(4)
tags_outer.addLayout(self._tags_layout)
for tag in data.get("tags", []):
self._add_tag_chip(tag)
add_btn = QtWidgets.QToolButton()
add_btn.setText("+")
add_btn.setFixedWidth(24)
add_btn.setToolTip("Add tag")
add_btn.clicked.connect(self._on_add_tag)
tags_outer.addWidget(add_btn)
tags_outer.addStretch()
content_layout.addWidget(tags_container)
# Fields section
fields = data.get("fields", {})
if fields:
group = QtWidgets.QGroupBox("Fields")
form = QtWidgets.QFormLayout(group)
form.setLabelAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
form.setHorizontalSpacing(16)
form.setVerticalSpacing(8)
for key, value in fields.items():
widget = self._make_field_widget(value)
self._field_widgets[key] = widget
form.addRow(key + ":", widget)
content_layout.addWidget(group)
content_layout.addStretch()
scroll.setWidget(content)
outer.addWidget(scroll, 1)
# Button bar
btn_bar = QtWidgets.QHBoxLayout()
btn_bar.addStretch()
self._save_btn = QtWidgets.QPushButton("Save")
self._save_btn.setEnabled(False)
self._save_btn.clicked.connect(self._on_save)
btn_bar.addWidget(self._save_btn)
self._reset_btn = QtWidgets.QPushButton("Reset")
self._reset_btn.setEnabled(False)
self._reset_btn.clicked.connect(self._on_reset)
btn_bar.addWidget(self._reset_btn)
outer.addLayout(btn_bar)
# -- field widgets -------------------------------------------------------
def _make_field_widget(self, value):
"""Create an appropriate edit widget based on the value type."""
if isinstance(value, bool):
cb = QtWidgets.QCheckBox()
cb.setChecked(value)
cb.stateChanged.connect(self._on_edited)
return cb
if isinstance(value, (int, float)):
spin = QtWidgets.QDoubleSpinBox()
spin.setDecimals(4)
spin.setRange(-1e9, 1e9)
spin.setValue(float(value))
spin.valueChanged.connect(self._on_edited)
return spin
le = QtWidgets.QLineEdit()
le.setText(str(value) if value is not None else "")
le.textChanged.connect(self._on_edited)
return le
def _read_field_widget(self, widget):
"""Read a value back from a field widget."""
if isinstance(widget, QtWidgets.QCheckBox):
return widget.isChecked()
if isinstance(widget, QtWidgets.QDoubleSpinBox):
return widget.value()
return widget.text()
# -- tag management ------------------------------------------------------
def _add_tag_chip(self, tag_text):
"""Add a removable tag chip to the tags row."""
chip = QtWidgets.QFrame()
chip.setStyleSheet(
"QFrame { border: 1px solid palette(mid); "
"border-radius: 8px; padding: 2px 4px; }"
)
chip_layout = QtWidgets.QHBoxLayout(chip)
chip_layout.setContentsMargins(4, 0, 0, 0)
chip_layout.setSpacing(2)
chip_layout.addWidget(QtWidgets.QLabel(tag_text))
remove_btn = QtWidgets.QToolButton()
remove_btn.setText("\u00d7") # ×
remove_btn.setFixedSize(16, 16)
remove_btn.setStyleSheet("QToolButton { border: none; }")
remove_btn.clicked.connect(
lambda checked=False, c=chip: self._remove_tag_chip(c)
)
chip_layout.addWidget(remove_btn)
self._tags_layout.addWidget(chip)
def _remove_tag_chip(self, chip):
"""Remove a tag chip and mark dirty."""
self._tags_layout.removeWidget(chip)
chip.deleteLater()
self._on_edited()
def _on_add_tag(self):
"""Prompt for a new tag and add it."""
text, ok = QtWidgets.QInputDialog.getText(self, "Add Tag", "Tag name:")
if ok and text.strip():
self._add_tag_chip(text.strip())
self._on_edited()
def _get_tags(self):
"""Collect current tag strings from the chip widgets."""
tags = []
for i in range(self._tags_layout.count()):
chip = self._tags_layout.itemAt(i).widget()
if chip is None:
continue
label = chip.findChild(QtWidgets.QLabel)
if label:
tags.append(label.text())
return tags
# -- dirty tracking / save / reset ---------------------------------------
def _on_edited(self, *args):
"""Mark the object dirty and enable Save/Reset."""
self._obj.Proxy.mark_dirty()
self._save_btn.setEnabled(True)
self._reset_btn.setEnabled(True)
def _collect_data(self):
"""Build a metadata dict from current widget state."""
return {
"schema": self._schema_name,
"lifecycle": self._lifecycle_combo.currentText(),
"tags": self._get_tags(),
"fields": {
key: self._read_field_widget(w)
for key, w in self._field_widgets.items()
},
}
def _on_save(self):
"""Write current form state to obj.RawContent."""
new_data = self._collect_data()
self._obj.RawContent = json.dumps(new_data, indent=2)
self._obj.Proxy.clear_dirty()
self._original_data = copy.deepcopy(new_data)
self._save_btn.setEnabled(False)
self._reset_btn.setEnabled(False)
def _on_reset(self):
"""Revert all fields to last-saved state."""
# Clear existing field widgets and tags
self._field_widgets.clear()
for i in reversed(range(self._tags_layout.count())):
w = self._tags_layout.itemAt(i).widget()
if w:
w.deleteLater()
# Repopulate from original data
data = self._original_data
# Lifecycle
idx = self._lifecycle_combo.findText(data.get("lifecycle", "draft"))
if idx >= 0:
self._lifecycle_combo.blockSignals(True)
self._lifecycle_combo.setCurrentIndex(idx)
self._lifecycle_combo.blockSignals(False)
# Tags
for tag in data.get("tags", []):
self._add_tag_chip(tag)
# Fields — find the QGroupBox and rebuild its form
for child in self.findChildren(QtWidgets.QGroupBox):
if child.title() == "Fields":
form = child.layout()
# Clear existing rows
while form.count():
item = form.takeAt(0)
if item.widget():
item.widget().deleteLater()
# Rebuild
for key, value in data.get("fields", {}).items():
widget = self._make_field_widget(value)
self._field_widgets[key] = widget
form.addRow(key + ":", widget)
break
self._obj.Proxy.clear_dirty()
self._save_btn.setEnabled(False)
self._reset_btn.setEnabled(False)
# -- close guard ---------------------------------------------------------
def closeEvent(self, event):
if self._obj.Proxy.is_dirty():
reply = QtWidgets.QMessageBox.question(
self,
"Unsaved Changes",
"Metadata has unsaved changes. Discard?",
QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel,
QtWidgets.QMessageBox.Cancel,
)
if reply == QtWidgets.QMessageBox.Cancel:
event.ignore()
return
self._obj.Proxy.clear_dirty()
event.accept()
# ---------------------------------------------------------------------------
# Viewer factory
# ---------------------------------------------------------------------------
_VIEWER_REGISTRY = {
"silo/manifest.json": SiloManifestViewer,
"silo/metadata.json": SiloMetadataEditor,
}