Merge pull request 'feat(create): metadata editor — editable form with dirty tracking and save-back' (#270) from feat/metadata-editor into main
Some checks failed
Build and Test / build (push) Has been cancelled

Reviewed-on: #270
This commit was merged in pull request #270.
This commit is contained in:
2026-02-19 00:42:54 +00:00
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) _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" KC_VERSION = "1.0"
@@ -75,6 +89,8 @@ class _KcFormatObserver:
_silo_cache.pop(filename, None) _silo_cache.pop(filename, None)
return return
entries = _silo_cache.pop(filename, None) entries = _silo_cache.pop(filename, None)
if entries is None:
entries = {}
for _hook in _pre_reinject_hooks: for _hook in _pre_reinject_hooks:
try: try:
_hook(doc, filename, entries) _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 Each silo/ ZIP entry in a .kc file gets one SiloViewerObject in the
FreeCAD document tree. All properties are Transient so they are never FreeCAD document tree. All properties are Transient so they are never
@@ -51,6 +51,18 @@ class SiloViewerObject:
def execute(self, obj): def execute(self, obj):
pass 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): def __getstate__(self):
return None 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. Each viewer is a plain QWidget suitable for embedding in an MDI subwindow.
The ``create_viewer_widget`` factory routes a SiloViewerObject to the The ``create_viewer_widget`` factory routes a SiloViewerObject to the
appropriate viewer class based on its SiloPath property. appropriate viewer class based on its SiloPath property.
""" """
import copy
import json import json
import FreeCAD import FreeCAD
@@ -124,12 +125,311 @@ def _add_row(form, label_text, display_val, raw_val, copyable):
form.addRow(label_text + ":", value_label) 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 factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_VIEWER_REGISTRY = { _VIEWER_REGISTRY = {
"silo/manifest.json": SiloManifestViewer, "silo/manifest.json": SiloManifestViewer,
"silo/metadata.json": SiloMetadataEditor,
} }