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