From 9fad317891fcc1fa38045b517121d5c0f990b2ef Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Sat, 18 Apr 2026 21:10:34 +0200 Subject: [PATCH] Feat: XML-Knoten-Bearbeiten-Dialog implementiert (v1.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DocuMentor.wxs | 2 +- THIRD_PARTY_LICENSES.txt | 2 +- installer.iss | 2 +- pyproject.toml | 2 +- src/ui/XmlToXslAssignDialog.py | 117 ++++++++++++++++++++---- src/ui/mixins/tree_manager.py | 158 ++++++++++++++++++++++++++++++++- uv.lock | 2 +- 7 files changed, 262 insertions(+), 23 deletions(-) diff --git a/DocuMentor.wxs b/DocuMentor.wxs index af1d10c..f841036 100644 --- a/DocuMentor.wxs +++ b/DocuMentor.wxs @@ -4,7 +4,7 @@ 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() diff --git a/src/ui/mixins/tree_manager.py b/src/ui/mixins/tree_manager.py index 9be7261..f28806e 100644 --- a/src/ui/mixins/tree_manager.py +++ b/src/ui/mixins/tree_manager.py @@ -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): """ diff --git a/uv.lock b/uv.lock index e3663d7..b078643 100644 --- a/uv.lock +++ b/uv.lock @@ -34,7 +34,7 @@ wheels = [ [[package]] name = "documentor" -version = "1.5.1" +version = "1.6.0" source = { virtual = "." } dependencies = [ { name = "connectorx" },