Feat: XML-Knoten-Bearbeiten-Dialog implementiert (v1.6.0)

Neuer Dialog ermöglicht es, einem XML-Knoten XSL-Zuordnungen hinzuzufügen
oder zu entfernen. XmlToXslAssignDialog wiederverwendet mit edit_mode,
Vorauswahl per preselected_xsl_ids und get_selection_diff(). Beim Entfernen
werden zugehörige PDF-Dateien gelöscht; bei verbleibend leerer Zuordnung
wird das physische Löschen der XML-Datei angeboten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 21:10:34 +02:00
parent 762523d669
commit 9fad317891
7 changed files with 262 additions and 23 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<!-- Paket-Definition (ersetzt Product in v4) -->
<Package
Name="DocuMentor"
Version="1.5.1"
Version="1.6.0"
Manufacturer="Vitali Graf / Software- und Datenbankentwicklung"
UpgradeCode="F498B66C-726D-44AA-95F4-CB4FBDCEF26E"
Language="1031"
+1 -1
View File
@@ -253,5 +253,5 @@ HINWEISE
================================================================================
Stand: April 2026
Erstellt für: DocuMentor v1.5.1
Erstellt für: DocuMentor v1.6.0
================================================================================
+1 -1
View File
@@ -10,7 +10,7 @@
; Build-Befehl: iscc installer.iss
#define MyAppName "DocuMentor"
#define MyAppVersion "1.5.1"
#define MyAppVersion "1.6.0"
#define MyAppPublisher "Ihr Name/Organisation"
#define MyAppURL "https://github.com/yourusername/xsl-validator"
#define MyAppExeName "DocuMentor.exe"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "DocuMentor"
version = "1.5.1"
version = "1.6.0"
description = "Professionelle XSL-Transformations-Verwaltung und PDF-Generierung"
readme = "README.md"
license = {text = "MIT"}
+101 -16
View File
@@ -13,14 +13,23 @@ logger = logging.getLogger(__name__)
class XmlToXslAssignDialog(QDialog):
"""Dialog zur Zuordnung einer XML-Datei zu XSL-Knoten."""
def __init__(self, parent=None, xml_file_path=None, project_nodes=None):
def __init__(
self,
parent=None,
xml_file_path=None,
project_nodes=None,
preselected_xsl_ids: set | None = None,
edit_mode: bool = False,
):
"""
Initialisiert den Dialog.
Args:
parent: Übergeordnetes Widget
xml_file_path: Pfad zur XML-Datei
project_nodes: Liste der Projekt-Knoten
preselected_xsl_ids: Set von XslFile-IDs (tuple), deren Checkbox initial angehakt sein soll
edit_mode: True = Bearbeiten-Modus (Zuordnungen ändern), False = Neu-Zuordnen
"""
super().__init__(parent)
@@ -31,9 +40,23 @@ class XmlToXslAssignDialog(QDialog):
# Parameter speichern
self.xml_file_path = Path(xml_file_path) if xml_file_path else None
self.project_nodes = project_nodes or []
self.preselected_xsl_ids: set = set(preselected_xsl_ids) if preselected_xsl_ids else set()
self.edit_mode = edit_mode
# Dictionary zum Speichern der Checkbox-Referenzen
self.xsl_checkboxes = {} # {xsl_node_id: checkbox}
self.xsl_checkboxes = {} # {python_id(node): checkbox}
self.xsl_nodes = {} # {python_id(node): XslFile}
# Initial ausgewählte XslFile-IDs (tuple), um Diff beim Accept zu berechnen
self._initial_selected_ids: set = set()
# Edit-Modus: UI anpassen
if self.edit_mode:
self.setWindowTitle("XML-Zuordnungen bearbeiten")
self.ui.infoLabel.setText(
"Passen Sie die Zuordnungen der XML-Datei an. Hinzufügen per Haken, "
"Entfernen durch Abhaken (zugehörige PDFs werden gelöscht):"
)
self.ui.alle_xml.setVisible(False)
# Signale verbinden
self.ui.selectAllButton.clicked.connect(self.select_all)
@@ -45,6 +68,10 @@ class XmlToXslAssignDialog(QDialog):
# Daten laden
self._load_data()
# Duplikat-Warnung nur im Edit-Modus (um bestehende Aufrufer nicht bei jeder XML zu stören)
if self.edit_mode:
self._warn_on_duplicate_xsl_ids()
def _setup_tree(self):
"""Konfiguriert das TreeWidget."""
# Spaltenbreiten setzen
@@ -110,13 +137,19 @@ class XmlToXslAssignDialog(QDialog):
if isinstance(node, XslFile):
# Erstelle zentrierte Checkbox für XSL-Knoten
checkbox_widget, checkbox = self._create_centered_checkbox()
# Vorauswahl setzen, wenn ID in preselected_xsl_ids
if node.id in self.preselected_xsl_ids:
checkbox.setChecked(True)
self._initial_selected_ids.add(node.id)
# Setze das Widget in Spalte 2
self.ui.xslNodesTree.setItemWidget(item, 2, checkbox_widget)
# Speichere Checkbox-Referenz
# Speichere Checkbox- und Node-Referenzen (id() als Key, da XSL-IDs theoretisch doppelt sein können)
self.xsl_checkboxes[id(node)] = checkbox
self.xsl_nodes[id(node)] = node
logger.debug(f"Checkbox für XSL-Knoten '{node.bez}' hinzugefügt")
# Rekursiv für Kinder
@@ -141,6 +174,8 @@ class XmlToXslAssignDialog(QDialog):
# TreeWidget leeren
self.ui.xslNodesTree.clear()
self.xsl_checkboxes.clear()
self.xsl_nodes.clear()
self._initial_selected_ids.clear()
# Sortiere Root-Nodes alphabetisch nach ID
sorted_nodes = sorted(self.project_nodes, key=lambda node: node.id)
@@ -295,16 +330,66 @@ class XmlToXslAssignDialog(QDialog):
"""
return self.ui.alle_xml.isChecked()
def get_selection_diff(self) -> tuple[list, list]:
"""
Gibt die Änderungen der Auswahl gegenüber der Vorauswahl zurück.
Nützlich im Edit-Modus, um nur die tatsächlich geänderten Zuordnungen zu verarbeiten.
Returns:
tuple[list[XslFile], list[XslFile]]: (added_nodes, removed_nodes)
- added_nodes: XslFile-Objekte, die neu angehakt wurden
- removed_nodes: XslFile-Objekte, deren Haken entfernt wurde
"""
added_nodes = []
removed_nodes = []
for py_id, checkbox in self.xsl_checkboxes.items():
node = self.xsl_nodes.get(py_id)
if node is None:
continue
was_selected = node.id in self._initial_selected_ids
is_selected = checkbox.isChecked()
if is_selected and not was_selected:
added_nodes.append(node)
elif not is_selected and was_selected:
removed_nodes.append(node)
return added_nodes, removed_nodes
def _warn_on_duplicate_xsl_ids(self):
"""
Zeigt eine Warnung, wenn im Projekt mehrere XslFile-Instanzen mit identischer ID existieren.
Die ID soll eindeutig sein - Duplikate weisen auf einen Datenfehler hin.
"""
id_to_nodes: dict = {}
for py_id, node in self.xsl_nodes.items():
id_to_nodes.setdefault(node.id, []).append(node)
duplicates = {xsl_id: nodes for xsl_id, nodes in id_to_nodes.items() if len(nodes) > 1}
if not duplicates:
return
dup_lines = [f" - ID {xsl_id}: {len(nodes)}× ({', '.join(n.bez for n in nodes)})" for xsl_id, nodes in duplicates.items()]
logger.warning(f"Doppelte XSL-IDs im Projekt gefunden:\n{chr(10).join(dup_lines)}")
QMessageBox.warning(
self,
"Doppelte XSL-IDs",
"Im Projekt existieren XSL-Knoten mit identischer ID. "
"Die IDs sollten eindeutig sein:\n\n" + "\n".join(dup_lines),
)
def accept(self):
"""Überschreibt accept() um Validierung durchzuführen."""
selected_nodes = self.get_selected_xsl_nodes()
if not selected_nodes:
QMessageBox.warning(
self,
"Warnung",
"Bitte wählen Sie mindestens einen XSL-Knoten aus."
)
# Im Edit-Modus: 0 Auswahlen erlauben (bedeutet: XML überall entfernen)
if self.edit_mode:
super().accept()
return
selected_nodes = self.get_selected_xsl_nodes()
if not selected_nodes:
QMessageBox.warning(self, "Warnung", "Bitte wählen Sie mindestens einen XSL-Knoten aus.")
return
super().accept()
+156 -2
View File
@@ -30,6 +30,7 @@ from PySide6.QtWidgets import (
from conf import TreeNode, XslFile, XmlFile
from ui.TreeNodeEditDialog import TreeNodeEditDialog
from ui.XslFileEditDialog import XslFileEditDialog
from ui.XmlToXslAssignDialog import XmlToXslAssignDialog
class ItemType(Enum):
@@ -1397,9 +1398,162 @@ class TreeManagerMixin:
# Kontextmenü-Aktionen für XmlFile
def _edit_xml_file(self, item):
"""Bearbeitet eine XML-Datei."""
"""
Bearbeitet die Zuordnungen einer XML-Datei zu XSL-Knoten.
Öffnet den XmlToXslAssignDialog im Edit-Modus mit Vorauswahl aller bestehenden Zuordnungen.
"""
logger.debug(f"XmlFile bearbeiten: {item.text(0)}")
# TODO: Dialog zum Bearbeiten der XML-Datei öffnen
try:
if not hasattr(self, "project") or not self.project:
QMessageBox.warning(self, "Warnung", "Kein Projekt geladen. Bitte öffnen Sie zuerst ein Projekt.")
return
if not hasattr(self, "pdf_project") or not self.pdf_project:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen geladen.")
return
xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
if not xml_file_obj or not isinstance(xml_file_obj, XmlFile):
QMessageBox.warning(self, "Warnung", "Keine gültige XML-Datei gefunden.")
return
xml_path = xml_file_obj.xml
# Alle aktuellen XSL-IDs, an denen diese XML hängt
current_xsl_ids = self._find_xsl_ids_for_xml(xml_path)
# Hash von einer vorhandenen Instanz übernehmen (falls vorhanden)
existing_hash = self._find_existing_xml_hash(xml_path)
dialog = XmlToXslAssignDialog(
parent=self,
xml_file_path=xml_path,
project_nodes=self.pdf_project.nodes,
preselected_xsl_ids=current_xsl_ids,
edit_mode=True,
)
if dialog.exec() != XmlToXslAssignDialog.DialogCode.Accepted:
logger.debug("Bearbeiten abgebrochen")
return
added_nodes, removed_nodes = dialog.get_selection_diff()
if not added_nodes and not removed_nodes:
logger.debug("Keine Änderungen an den XML-Zuordnungen")
return
self._apply_xml_assignment_changes(xml_path, existing_hash, added_nodes, removed_nodes)
# Speichern und Baum neu laden
self._save_project_settings()
self._load_nodes_to_tree()
# Wenn keine Zuordnungen mehr bestehen: Physische Datei löschen anbieten
if not self._find_xsl_ids_for_xml(xml_path):
xml_file_path = Path(self.project.project_dir) / xml_path
if xml_file_path.exists():
reply = QMessageBox.question(
self,
"Physische Datei löschen",
f"Die XML-Datei '{xml_path.name}' ist keinem XSL-Knoten mehr zugeordnet.\n\n"
"Möchten Sie auch die physische Datei aus dem xml-Ordner löschen?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
try:
xml_file_path.unlink()
logger.info(f"Physische XML-Datei gelöscht: {xml_file_path}")
except Exception as e:
QMessageBox.warning(self, "Warnung", f"Fehler beim Löschen der physischen Datei:\n{str(e)}")
logger.info(
f"XML-Zuordnungen aktualisiert: {len(added_nodes)} hinzugefügt, {len(removed_nodes)} entfernt"
)
except Exception as e:
error_msg = f"Fehler beim Bearbeiten der XML-Zuordnungen: {str(e)}"
logger.exception(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _find_xsl_ids_for_xml(self, xml_path: Path) -> set:
"""
Findet alle XslFile-IDs (tuple), deren `xmls`-Liste den angegebenen Pfad enthält.
Args:
xml_path: Relativer Pfad zur XML-Datei
Returns:
set[tuple]: Set von XslFile-IDs
"""
found_ids: set = set()
if not self.pdf_project or not self.pdf_project.nodes:
return found_ids
def _walk(nodes):
for node in nodes:
if isinstance(node, XslFile):
if any(xml.xml == xml_path for xml in node.xmls):
found_ids.add(node.id)
elif isinstance(node, TreeNode) and node.children:
_walk(node.children)
_walk(self.pdf_project.nodes)
return found_ids
def _find_existing_xml_hash(self, xml_path: Path) -> str | None:
"""
Sucht den blake2b-Hash einer bereits zugeordneten XmlFile-Instanz.
Gibt den ersten gefundenen Hash zurück (oder None, wenn keiner vorhanden).
"""
if not self.pdf_project or not self.pdf_project.nodes:
return None
def _walk(nodes):
for node in nodes:
if isinstance(node, XslFile):
for xml_file in node.xmls:
if xml_file.xml == xml_path and xml_file.hashsum:
return xml_file.hashsum
elif isinstance(node, TreeNode) and node.children:
found = _walk(node.children)
if found:
return found
return None
return _walk(self.pdf_project.nodes)
def _apply_xml_assignment_changes(
self, xml_path: Path, hashsum: str | None, added_nodes: list, removed_nodes: list
):
"""
Wendet Änderungen an den XML-Zuordnungen an:
- Entfernt XmlFile-Instanzen aus `removed_nodes` und löscht zugehörige PDFs (falls Kombination nirgends mehr existiert)
- Fügt XmlFile-Instanzen zu `added_nodes` hinzu (mit übernommenem Hash)
Args:
xml_path: Relativer Pfad zur XML-Datei
hashsum: blake2b-Hash, der für neue Instanzen übernommen wird
added_nodes: Liste der XslFile-Objekte, denen die XML neu zugeordnet wird
removed_nodes: Liste der XslFile-Objekte, aus denen die XML entfernt wird
"""
# 1. Entfernen
for xsl_node in removed_nodes:
xsl_node.xmls = [x for x in xsl_node.xmls if x.xml != xml_path]
# PDFs nur löschen, wenn die Kombination nirgends mehr existiert
if not self._is_xml_xsl_combination_used_elsewhere(xml_path, xsl_node.id, xsl_node):
deleted = self._delete_pdf_files_for_xml_xsl_combination(xml_path, xsl_node.id)
if deleted:
logger.info(f"{deleted} PDF-Datei(en) für '{xml_path.name}' (XSL: {xsl_node.bez}) gelöscht")
# 2. Hinzufügen (Hash übernehmen, Duplikate im gleichen XslFile vermeiden)
for xsl_node in added_nodes:
if any(x.xml == xml_path for x in xsl_node.xmls):
logger.debug(f"XML '{xml_path.name}' bereits an XSL '{xsl_node.bez}' zugeordnet — übersprungen")
continue
xsl_node.xmls.append(XmlFile(xml=xml_path, hashsum=hashsum))
logger.info(f"XML '{xml_path.name}' zu XSL '{xsl_node.bez}' hinzugefügt")
def _delete_xml_file(self, item):
"""
Generated
+1 -1
View File
@@ -34,7 +34,7 @@ wheels = [
[[package]]
name = "documentor"
version = "1.5.1"
version = "1.6.0"
source = { virtual = "." }
dependencies = [
{ name = "connectorx" },