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
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user