""" TreeManagerMixin - Mixin für Tree-Widget-Operationen. Dieses Mixin enthält alle Methoden für die Verwaltung des TreeWidgets im MainWindow: - Setup und Styling - Kontextmenüs - Node-Operationen (Hinzufügen, Bearbeiten, Löschen) - Tree-Navigation und -Suche """ import logging import shutil import time from enum import Enum, auto from pathlib import Path from PySide6.QtCore import Qt from PySide6.QtGui import QAction, QIcon from PySide6.QtWidgets import ( QMenu, QTreeWidgetItem, QMessageBox, QFileDialog, QWidget, QHBoxLayout, QLabel, QProgressBar, ) from conf import TreeNode, XslFile, XmlFile from ui.TreeNodeEditDialog import TreeNodeEditDialog from ui.XslFileEditDialog import XslFileEditDialog from ui.XmlToXslAssignDialog import XmlToXslAssignDialog class ItemType(Enum): TREE_NODE = auto() XSL_FILE = auto() XML_FILE = auto() UNKNOWN = auto() logger = logging.getLogger(__name__) class TreeManagerMixin: """ Mixin-Klasse für Tree-Widget-Operationen. Dieses Mixin erwartet, dass die verwendende Klasse folgende Attribute hat: - self.ui: UI-Objekt mit treeWidget - self.project: Aktuelles Projekt - self.pdf_project: Projekt-Daten - self.xml_item_map: Dict für XML-Item-Mapping """ def _setup_tree_context_menu(self): """Richtet das Kontextmenü für das TreeWidget ein.""" # Aktiviere Kontextmenü für das TreeWidget self.ui.treeWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.ui.treeWidget.customContextMenuRequested.connect(self._show_tree_context_menu) # Verbinde Selection-Changed-Signal für automatisches Laden von Diff-PDFs self.ui.treeWidget.itemSelectionChanged.connect(self._on_tree_selection_changed) logger.debug("Kontextmenü und Selection-Handler für TreeWidget eingerichtet") def _show_tree_context_menu(self, position): """ Zeigt das Kontextmenü für das TreeWidget an. Args: position: Position des Rechtsklicks """ # Hole das Item an der Position item = self.ui.treeWidget.itemAt(position) if not item: # Kein Item gefunden - zeige Kontextmenü für Root-Elemente node_type = ItemType.UNKNOWN context_menu = self._create_context_menu_for_type(node_type, None) else: # Bestimme den Node-Typ basierend auf dem Item node_type = self._get_node_type_from_item(item) # Erstelle das entsprechende Kontextmenü context_menu = self._create_context_menu_for_type(node_type, item) if context_menu: # Zeige das Kontextmenü an der globalen Position global_pos = self.ui.treeWidget.mapToGlobal(position) context_menu.exec(global_pos) def _on_tree_selection_changed(self): """ Handler für Änderungen der Tree-Selektion. Lädt automatisch Diff-PDFs wenn ein XML-Knoten mit Diff-PDF ausgewählt wird. Leert den Viewer wenn ein Knoten ohne Diff-PDF ausgewählt wird. """ try: logger.debug("Tree-Selektion geändert") # Hole aktuell selektierte Items selected_items = self.ui.treeWidget.selectedItems() if not selected_items or not self.project: # Keine Selektion oder kein Projekt - Viewer leeren logger.debug( f"Keine Selektion oder kein Projekt: selected_items={len(selected_items) if selected_items else 0}, project={self.project is not None}" ) if self.pdf_documents: self._clear_pdf_viewer() return # Erstes selektiertes Item verwenden item = selected_items[0] # Prüfe ob es ein XML-Item ist node_type = self._get_node_type_from_item(item) logger.debug(f"Selektierter Node-Typ: {node_type}") if node_type == ItemType.XML_FILE: # Hole XmlFile-Objekt und XSL-ID aus UserRole xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole) xsl_id_str = item.data(0, Qt.ItemDataRole.UserRole + 2) logger.debug(f"XML-File-Daten: xml_file_obj={xml_file_obj}, xsl_id_str={xsl_id_str}") if xml_file_obj and xsl_id_str: # Extrahiere Pfad aus XmlFile-Objekt xml_file_path = xml_file_obj.xml # Prüfe ob Diff-PDF existiert xml_stem = xml_file_path.stem pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf" diff_pdf_path = self.project.project_dir / "diff" / pdf_basename logger.debug(f"Prüfe Diff-PDF: {diff_pdf_path}, existiert={diff_pdf_path.exists()}") if diff_pdf_path.exists(): # Diff-PDF vorhanden - automatisch laden logger.info(f"XML-Knoten mit Diff-PDF ausgewählt: {pdf_basename}, lade automatisch") self._load_pdf_for_comparison(xml_file_path, xsl_id_str) else: # Kein Diff-PDF - Ref-PDF laden falls vorhanden, sonst Viewer leeren ref_pdf_path = self.project.project_dir / "ref" / pdf_basename if ref_pdf_path.exists(): logger.info(f"XML-Knoten ohne Diff-PDF, lade Ref-PDF: {pdf_basename}") self._load_ref_pdf_for_display(xml_file_path, xsl_id_str) elif self.pdf_documents: logger.debug("XML-Knoten ohne Diff-PDF und ohne Ref-PDF, leere Viewer") self._clear_pdf_viewer() else: logger.debug("XML-File-Daten fehlen (xml_file_obj oder xsl_id_str ist None)") else: # Kein XML-Item - Viewer leeren falls noch ein PDF geladen ist if self.pdf_documents: logger.debug(f"Nicht-XML-Knoten ausgewählt ({node_type}), leere Viewer") self._clear_pdf_viewer() except Exception as e: logger.error(f"Fehler beim Verarbeiten der Tree-Selektion: {e}", exc_info=True) def _get_node_type_from_item(self, item): """ Bestimmt den Node-Typ basierend auf dem TreeWidgetItem. Args: item: Das TreeWidgetItem Returns: str: Der Node-Typ ('TreeNode', 'XslFile', 'XmlFile' oder 'Unknown') """ try: # Prüfe ob das Item ein Parent hat (dann ist es ein Child-Item) parent_item = item.parent() if parent_item: # Child-Item - prüfe ob es ein XML-File ist text = item.text(0) if text.startswith("XML:"): return ItemType.XML_FILE else: # Könnte ein TreeNode-Child oder XslFile-Child sein # Prüfe den Parent-Typ parent_type = self._get_node_type_from_item(parent_item) if parent_type == ItemType.XSL_FILE: return ItemType.XML_FILE else: # Rekursiv bestimmen basierend auf gespeicherten Daten return self._determine_node_type_from_data(item) else: # Root-Item - bestimme Typ basierend auf gespeicherten Daten return self._determine_node_type_from_data(item) except Exception as e: logger.error(f"Fehler beim Bestimmen des Node-Typs: {e}") return ItemType.UNKNOWN def _determine_node_type_from_data(self, item): """ Bestimmt den Node-Typ basierend auf den gespeicherten Daten im Item. Args: item: Das TreeWidgetItem Returns: str: Der Node-Typ ('TreeNode', 'XslFile' oder 'Unknown') """ try: # Hole das gespeicherte Node-Objekt direkt node = item.data(0, Qt.ItemDataRole.UserRole) if not node: return ItemType.UNKNOWN # Bestimme den Typ direkt vom Node-Objekt if isinstance(node, TreeNode): return ItemType.TREE_NODE elif isinstance(node, XslFile): return ItemType.XSL_FILE elif isinstance(node, XmlFile): return ItemType.XML_FILE return ItemType.UNKNOWN except Exception as e: logger.error(f"Fehler beim Bestimmen des Node-Typs aus Daten: {e}") return ItemType.UNKNOWN def _find_item_by_node(self, node_obj): """ Findet ein TreeWidgetItem basierend auf einem Node-Objekt. Args: node_obj: Das Node-Objekt (TreeNode, XslFile oder XmlFile) Returns: QTreeWidgetItem oder None wenn nicht gefunden """ def search_recursive(item): """Rekursive Suche durch TreeWidget.""" # Prüfe aktuelles Item item_node = item.data(0, Qt.ItemDataRole.UserRole) if item_node is node_obj: return item # Durchsuche Kinder for i in range(item.childCount()): child = item.child(i) result = search_recursive(child) if result: return result return None # Durchsuche alle Root-Items for i in range(self.ui.treeWidget.topLevelItemCount()): root_item = self.ui.treeWidget.topLevelItem(i) result = search_recursive(root_item) if result: return result return None def _find_node_by_id(self, nodes, target_id): """ Sucht rekursiv nach einem Node mit der angegebenen ID. Args: nodes: Liste der Nodes zum Durchsuchen target_id: Die zu suchende ID Returns: TreeNode|XslFile|None: Der gefundene Node oder None """ for node in nodes: if node.id == target_id: return node # Rekursiv in Knotenn suchen (nur bei TreeNode) if isinstance(node, TreeNode) and node.children: found = self._find_node_by_id(node.children, target_id) if found: return found return None def _create_context_menu_for_type(self, node_type, item): """ Erstellt das Kontextmenü für den angegebenen Node-Typ. Args: node_type: Der Typ des Nodes ('TreeNode', 'XslFile', 'XmlFile') item: Das TreeWidgetItem Returns: QMenu: Das erstellte Kontextmenü oder None """ try: menu = QMenu(self) if node_type == ItemType.TREE_NODE: # Kontextmenü für TreeNode action_add_child = QAction("Unterknoten hinzufügen", self) action_add_child.setIcon(QIcon(QIcon.fromTheme("folder-new"))) action_add_child.triggered.connect(lambda: self._add_tree_node_child(item)) menu.addAction(action_add_child) action_add_xsl = QAction("XSL-Datei hinzufügen", self) action_add_xsl.setIcon(QIcon(QIcon.fromTheme("document-new"))) action_add_xsl.triggered.connect(lambda: self._add_xsl_file_to_node(item)) menu.addAction(action_add_xsl) menu.addSeparator() # Transformations-Aktionen (nur aktiv wenn XML-Dateien vorhanden) tree_node_obj = item.data(0, Qt.ItemDataRole.UserRole) if item else None has_xml_files = bool(tree_node_obj and self._has_xml_files_recursive(tree_node_obj)) action_transform = QAction("Alle XML-Dateien transformieren", self) action_transform.setIcon(QIcon(QIcon.fromTheme("system-run"))) action_transform.triggered.connect(lambda: self._transform_tree_node(item)) action_transform.setEnabled(has_xml_files) menu.addAction(action_transform) action_transform_force = QAction("Alle XML-Dateien neu transformieren (force)", self) action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh"))) action_transform_force.triggered.connect(lambda: self._transform_tree_node(item, force=True)) action_transform_force.setEnabled(has_xml_files) menu.addAction(action_transform_force) menu.addSeparator() # Aktion "Alle Änderungen übernehmen" (nur aktiv wenn Diff-PDFs vorhanden) diff_pdfs = self._collect_all_diff_pdfs_under_node(tree_node_obj, item) if tree_node_obj else [] has_diff_pdfs = len(diff_pdfs) > 0 action_accept_all = QAction("Alle Änderungen übernehmen", self) action_accept_all.setIcon(QIcon(QIcon.fromTheme("emblem-default"))) action_accept_all.triggered.connect(lambda: self._accept_all_changes_under_node(item)) action_accept_all.setEnabled(has_diff_pdfs) menu.addAction(action_accept_all) menu.addSeparator() action_edit = QAction("Bearbeiten", self) action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties))) action_edit.triggered.connect(lambda: self._edit_tree_node(item)) menu.addAction(action_edit) action_delete = QAction("Löschen", self) action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete))) action_delete.triggered.connect(lambda: self._delete_tree_node(item)) menu.addAction(action_delete) elif node_type == ItemType.XSL_FILE: # Kontextmenü für XslFile action_add_xml = QAction("XML-Datei hinzufügen", self) action_add_xml.setIcon(QIcon(QIcon.fromTheme("document-new"))) action_add_xml.triggered.connect(lambda: self._add_xml_file_to_xsl(item)) menu.addAction(action_add_xml) menu.addSeparator() # Transformations-Aktionen (nur aktiv wenn XML-Dateien vorhanden) xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole) if item else None has_xml_files = bool(xsl_file_obj and xsl_file_obj.xmls) action_transform = QAction("Alle XML-Dateien transformieren", self) action_transform.setIcon(QIcon(QIcon.fromTheme("system-run"))) action_transform.triggered.connect(lambda: self._transform_xsl_file(item)) action_transform.setEnabled(has_xml_files) menu.addAction(action_transform) action_transform_force = QAction("Alle XML-Dateien neu transformieren (force)", self) action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh"))) action_transform_force.triggered.connect(lambda: self._transform_xsl_file(item, force=True)) action_transform_force.setEnabled(has_xml_files) menu.addAction(action_transform_force) menu.addSeparator() # Aktion "Alle Änderungen übernehmen" (nur aktiv wenn Diff-PDFs vorhanden) diff_pdfs = self._collect_all_diff_pdfs_under_node(xsl_file_obj, item) if xsl_file_obj else [] has_diff_pdfs = len(diff_pdfs) > 0 action_accept_all = QAction("Alle Änderungen übernehmen", self) action_accept_all.setIcon(QIcon(QIcon.fromTheme("emblem-default"))) action_accept_all.triggered.connect(lambda: self._accept_all_changes_under_node(item)) action_accept_all.setEnabled(has_diff_pdfs) menu.addAction(action_accept_all) menu.addSeparator() action_deps = QAction("Abhängigkeiten anzeigen", self) action_deps.setIcon(QIcon(QIcon.fromTheme("view-list-tree"))) action_deps.triggered.connect(lambda: self._show_xsl_dependencies(item)) menu.addAction(action_deps) menu.addSeparator() action_edit = QAction("Bearbeiten", self) action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties))) action_edit.triggered.connect(lambda: self._edit_xsl_file(item)) menu.addAction(action_edit) action_delete = QAction("Löschen", self) action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete))) action_delete.triggered.connect(lambda: self._delete_xsl_file(item)) menu.addAction(action_delete) elif node_type == ItemType.XML_FILE: # Kontextmenü für XmlFile # Transformations-Aktionen action_transform = QAction("Transformieren", self) action_transform.setIcon(QIcon(QIcon.fromTheme("system-run"))) action_transform.triggered.connect(lambda: self._transform_xml_file(item)) menu.addAction(action_transform) action_transform_force = QAction("Neu transformieren (force)", self) action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh"))) action_transform_force.triggered.connect(lambda: self._transform_xml_file(item, force=True)) menu.addAction(action_transform_force) menu.addSeparator() action_edit = QAction("Bearbeiten", self) action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties))) action_edit.triggered.connect(lambda: self._edit_xml_file(item)) menu.addAction(action_edit) action_delete = QAction("Löschen", self) action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete))) action_delete.triggered.connect(lambda: self._delete_xml_file(item)) menu.addAction(action_delete) else: # Unbekannter Typ oder leerer Bereich - Menü für Root-Elemente action_add_tree_node = QAction("Unterknoten hinzufügen", self) action_add_tree_node.setIcon(QIcon(QIcon.fromTheme("folder-new"))) action_add_tree_node.triggered.connect(lambda: self._add_root_tree_node()) menu.addAction(action_add_tree_node) return menu except Exception as e: logger.error(f"Fehler beim Erstellen des Kontextmenüs: {e}") return None def _load_nodes_to_tree(self): """ Lädt die Nodes aus den Projekt-Einstellungen in das TreeWidget. Sortiert die Items alphabetisch nach ihrer ID. """ logger.info("Lade Nodes in TreeWidget...") try: # TreeWidget leeren self.ui.treeWidget.clear() # Lösche XML-Item-Map self.xml_item_map.clear() # Prüfe ob pdf_project existiert und Nodes hat if not hasattr(self, "pdf_project") or not self.pdf_project: logger.warning("Keine Projekt-Einstellungen verfügbar") return if not self.pdf_project.nodes: logger.warning("Keine Nodes in den Projekt-Einstellungen gefunden") return # Sortiere Root-Nodes alphabetisch nach ID sorted_nodes = sorted(self.pdf_project.nodes, key=lambda node: node.id) # Lade alle Root-Nodes (sortiert) for node in sorted_nodes: tree_item = self._create_tree_item_from_node(node) self.ui.treeWidget.addTopLevelItem(tree_item) logger.info(f"{len(self.pdf_project.nodes)} Root-Nodes in TreeWidget geladen (alphabetisch sortiert)") # Aktualisiere Diff-PDF-Anzahl und Icons nach dem Laden self._update_all_diff_pdf_counts() self._update_diff_icons_for_existing_pdfs() # Stelle Expand-Status wieder her self._restore_expanded_state() # Suchfilter erneut anwenden, falls aktiv search_text = self.ui.searchEdit.text() if search_text: self._filter_tree(search_text) except Exception as e: logger.error(f"Fehler beim Laden der Nodes in TreeWidget: {e}") def _filter_tree(self, text: str): """ Filtert das TreeWidget nach TreeNode- und XslFile-Bezeichnungen sowie XSL-Dateinamen. Sichtbarkeitsregeln: - TreeNode/XslFile wird angezeigt, wenn .bez oder (bei XslFile) der Dateiname den Suchtext enthält (case-insensitive) - Wenn ein Kind matcht, bleibt der übergeordnete Knoten sichtbar und wird expandiert - Wenn ein TreeNode matcht, bleiben alle Kinder sichtbar - XmlFile-Items folgen der Sichtbarkeit ihres Eltern-XslFile - Leerer Suchtext blendet alles ein und stellt den Expand-Status wieder her Args: text: Suchtext für den Filter """ search_lower = text.strip().lower() if not search_lower: # Alles einblenden, kollabieren und gespeicherten Expand-Status wiederherstellen for i in range(self.ui.treeWidget.topLevelItemCount()): item = self.ui.treeWidget.topLevelItem(i) self._reset_filter_recursive(item) self._restore_expanded_state() logger.debug("Baumfilter zurückgesetzt") return for i in range(self.ui.treeWidget.topLevelItemCount()): item = self.ui.treeWidget.topLevelItem(i) self._apply_filter_recursive(item, search_lower) logger.debug(f"Baumfilter angewendet: '{text}'") def _apply_filter_recursive(self, item: QTreeWidgetItem, search_lower: str) -> bool: """ Wendet den Suchfilter rekursiv auf ein Item und seine Kinder an. Args: item: Das zu prüfende TreeWidgetItem search_lower: Suchtext in Kleinbuchstaben Returns: bool: True wenn dieses Item oder ein Kind den Suchtext enthält """ node = item.data(0, Qt.ItemDataRole.UserRole) # Prüfe ob dieses Item selbst matcht (TreeNode/XslFile: .bez, XslFile zusätzlich: .xsl_file) self_matches = False if isinstance(node, (TreeNode, XslFile)): bez_text = str(node.bez).lower() if node.bez else "" self_matches = search_lower in bez_text if not self_matches and isinstance(node, XslFile): xsl_filename = node.xsl_file.name.lower() if node.xsl_file else "" self_matches = search_lower in xsl_filename # Wenn dieses Item matcht, sichtbar machen und alle Kinder einblenden if self_matches: item.setHidden(False) self._set_item_visible_recursive(item, True) item.setExpanded(True) return True # Prüfe Kinder rekursiv any_child_matches = False for child_idx in range(item.childCount()): child = item.child(child_idx) if self._apply_filter_recursive(child, search_lower): any_child_matches = True # Item sichtbar lassen wenn ein Kind matcht, expandieren für Übersicht if any_child_matches: item.setHidden(False) item.setExpanded(True) else: item.setHidden(True) return any_child_matches def _set_item_visible_recursive(self, item: QTreeWidgetItem, visible: bool): """ Setzt die Sichtbarkeit eines Items und aller seiner Kinder. Args: item: Das TreeWidgetItem visible: Ob das Item sichtbar sein soll """ item.setHidden(not visible) for child_idx in range(item.childCount()): self._set_item_visible_recursive(item.child(child_idx), visible) def _reset_filter_recursive(self, item: QTreeWidgetItem): """ Blendet ein Item und alle Kinder ein und kollabiert sie. Kombiniert Sichtbarkeit und Expand-Reset in einem Durchlauf. Args: item: Das TreeWidgetItem """ item.setHidden(False) item.setExpanded(False) for child_idx in range(item.childCount()): self._reset_filter_recursive(item.child(child_idx)) def _create_tree_item_from_node(self, node): """ Erstellt ein QTreeWidgetItem aus einem TreeNode oder XslFile. Speichert die vollständigen Node-Daten für spätere Verwendung. Args: node: TreeNode oder XslFile Objekt Returns: QTreeWidgetItem: Das erstellte Tree-Item mit vollständigen Node-Daten """ try: # Erstelle Tree-Item item = QTreeWidgetItem() # Setze die Bezeichnung in Spalte 0 bez_text = str(node.bez) if node.bez else "" item.setText(0, bez_text) # Speichere das komplette Node-Objekt als UserRole-Daten # Dies ermöglicht späteren Zugriff auf alle Node-Eigenschaften item.setData(0, Qt.ItemDataRole.UserRole, node) # Setze Icon basierend auf Node-Typ if isinstance(node, TreeNode): # TreeNode: Ordner-Icon folder_icon = QIcon.fromTheme(QIcon.ThemeIcon.FolderOpen) if folder_icon.isNull(): folder_icon = QIcon.fromTheme("folder") item.setIcon(0, folder_icon) elif isinstance(node, XslFile): # XslFile: Code/Script-Icon für XSL-Dateien xsl_icon = QIcon.fromTheme("text-x-script") if xsl_icon.isNull(): xsl_icon = QIcon.fromTheme("text-x-generic") item.setIcon(0, xsl_icon) if isinstance(node, TreeNode): # Speichere zusätzlich die Node-ID in UserRole+1 für Kompatibilität item.setData(0, Qt.ItemDataRole.UserRole + 1, node.id) # Lade Knoten rekursiv (sortiert nach ID) if node.children: sorted_children = sorted(node.children, key=lambda child: child.id) for child in sorted_children: child_item = self._create_tree_item_from_node(child) item.addChild(child_item) # Setze Diff-PDF-Anzahl in Spalte 1 diff_count = self._count_diff_pdfs_under_node(node, item) if diff_count > 0: item.setText(1, str(diff_count)) elif isinstance(node, XslFile): # Speichere zusätzlich die Node-ID in UserRole+1 für Kompatibilität item.setData(0, Qt.ItemDataRole.UserRole + 1, node.id) # Setze Diff-PDF-Anzahl in Spalte 1 diff_count = self._count_diff_pdfs_under_node(node, item) if diff_count > 0: item.setText(1, str(diff_count)) # Prüfe ob XSL-Datei existiert xsl_file_missing = False if hasattr(self, "project") and self.project: from conf import app_settings # Hole XSL-Verzeichnis aus Projekt-Konfiguration xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None) if xsl_dir: # Konstruiere absoluten Pfad zur XSL-Datei xsl_file_abs = xsl_dir.path_to_root_dir / node.xsl_file if not xsl_file_abs.exists(): xsl_file_missing = True item.setDisabled(True) item.setToolTip(0, f"XSL-Datei nicht gefunden: {xsl_file_abs}") logger.warning(f"XSL-Datei nicht vorhanden: {xsl_file_abs}") else: # Tooltip mit Abhängigkeiten (import/include) setzen self._set_xsl_dependency_tooltip(item, xsl_file_abs) # Lade XML-Dateien als Knoten if node.xmls: for xml in node.xmls: xml_item = QTreeWidgetItem() xml_item.setText(0, f"XML: {xml.xml.name}") # Speichere auch das XmlFile-Objekt für XML-Items xml_item.setData(0, Qt.ItemDataRole.UserRole, xml) xml_item.setData(0, Qt.ItemDataRole.UserRole + 1, f"xml_{xml.xml.name}") # Speichere XSL-ID in Spalte 0, UserRole+2 für einfachen Zugriff xsl_id_str = "_".join(str(x) for x in node.id) xml_item.setData(0, Qt.ItemDataRole.UserRole + 2, xsl_id_str) # Setze XML-Icon xml_icon = QIcon.fromTheme("text-xml") if xml_icon.isNull(): xml_icon = QIcon.fromTheme("application-xml") if xml_icon.isNull(): xml_icon = QIcon.fromTheme("text-x-generic") xml_item.setIcon(0, xml_icon) # Prüfe ob XML-Datei existiert und deaktiviere Knoten falls nicht # Wenn XSL-Datei fehlt, deaktiviere auch alle untergeordneten XML-Knoten if xsl_file_missing: xml_item.setDisabled(True) xml_item.setToolTip(0, "XML-Knoten deaktiviert: Übergeordnete XSL-Datei fehlt") elif hasattr(self, "project") and self.project: xml_abs_path = self.project.project_dir / xml.xml if not xml_abs_path.exists(): xml_item.setDisabled(True) xml_item.setToolTip(0, f"XML-Datei nicht gefunden: {xml_abs_path}") logger.warning(f"XML-Datei nicht vorhanden: {xml_abs_path}") item.addChild(xml_item) # Speichere XML-Item für spätere Widget-Updates (Progress Bar, Icon) # Key: "xml_path|xsl_id" um mehrfache Verwendung derselben XML zu unterstützen xml_path_str = str(xml.xml) xsl_id_str = "_".join(str(x) for x in node.id) map_key = f"{xml_path_str}|{xsl_id_str}" self.xml_item_map[map_key] = xml_item logger.debug(f"XML-Item zur Map hinzugefügt: '{map_key}'") return item except Exception as e: logger.error(f"Fehler beim Erstellen des Tree-Items: {e}") # Fallback: Erstelle einfaches Item fallback_item = QTreeWidgetItem() fallback_item.setText(0, "Fehler beim Laden") fallback_item.setToolTip(0, str(e)) return fallback_item def _create_centered_progress_bar(self) -> tuple[QWidget, QProgressBar]: """ Erstellt eine linksbündige Progress Bar in einem Container-Widget. Returns: tuple: (container_widget, progress_bar) """ # Container-Widget erstellen container = QWidget() layout = QHBoxLayout(container) layout.setContentsMargins(0, 0, 0, 0) layout.setAlignment(Qt.AlignmentFlag.AlignLeft) # Progress Bar erstellen (indeterminate mode für pulsierenden Effekt) progress_bar = QProgressBar() progress_bar.setMinimum(0) progress_bar.setMaximum(0) # Pulsierend progress_bar.setMaximumWidth(80) # Kompakte Breite progress_bar.setMaximumHeight(16) # Kompakte Höhe progress_bar.setTextVisible(False) layout.addWidget(progress_bar) return container, progress_bar def _create_centered_diff_icon(self, xml_file_path: Path, xsl_id_str: str) -> QWidget: """ Erstellt ein linksbündiges, nicht-klickbares Icon für Diff-PDF. Args: xml_file_path: Pfad zur XML-Datei (relativ) xsl_id_str: XSL-ID als String (z.B. "2002_1_128") Returns: QWidget: Container mit Icon """ # Container-Widget container = QWidget() layout = QHBoxLayout(container) layout.setContentsMargins(0, 0, 0, 0) layout.setAlignment(Qt.AlignmentFlag.AlignLeft) # Icon-Label icon_label = QLabel() # Icon für Diff-View mit Fallbacks icon = QIcon.fromTheme("view-split-left-right") if icon.isNull(): icon = QIcon.fromTheme("vcs-diff") if icon.isNull(): icon = QIcon.fromTheme("system-search") # Letzter Fallback icon_label.setPixmap(icon.pixmap(16, 16)) icon_label.setToolTip("Diff-PDF vorhanden (wird automatisch geladen bei Selektion)") layout.addWidget(icon_label) return container # Kontextmenü-Aktionen für TreeNode def _add_tree_node_child(self, parent_item): """Fügt einen Unterknoten zu einem TreeNode hinzu.""" logger.debug(f"Unterknoten zu TreeNode hinzufügen: {parent_item.text(0)}") # TODO: Dialog zum Eingeben der Node-Daten öffnen def _add_xsl_file_to_node(self, parent_item): """Fügt eine XSL-Datei zu einem TreeNode hinzu.""" logger.debug(f"XSL-Datei zu TreeNode hinzufügen: {parent_item.text(0)}") # TODO: Dialog zum Auswählen der XSL-Datei öffnen def _edit_tree_node(self, item): """ Bearbeitet einen TreeNode. Args: item: Das TreeWidgetItem des TreeNode """ logger.debug(f"TreeNode bearbeiten: {item.text(0)}") try: # Hole das Node-Objekt aus dem TreeWidgetItem node = item.data(0, Qt.ItemDataRole.UserRole) if not node or not isinstance(node, TreeNode): QMessageBox.warning(self, "Warnung", "Kein gültiger TreeNode gefunden.") return # Prüfe ob Projekt verfügbar ist if not self.pdf_project: QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen verfügbar.") return # Sammle Eltern-Parameter parent_params = self._collect_parent_params(item) # Erstelle und zeige den Dialog dialog = TreeNodeEditDialog(self, node, parent_params) if dialog.exec() == TreeNodeEditDialog.DialogCode.Accepted: # Hole die bearbeiteten Daten data = dialog.get_data() if data: # Aktualisiere den Node node.bez = data["bez"] node.xslt_params = data["xslt_params"] logger.info(f"TreeNode '{node.bez}' wurde aktualisiert") logger.debug(f"XSLT-Parameter: {node.xslt_params}") # Speichere die Änderungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() # Wenn Force-Transformation gewünscht, führe sie aus if data.get("force_transform", False): # Finde das neue Item nach dem Neuladen new_item = self._find_item_by_node(node) if new_item: logger.info(f"Starte Force-Transformation für TreeNode '{node.bez}'") self._transform_tree_node(new_item, force=True) else: logger.warning(f"Konnte Item für TreeNode '{node.bez}' nicht finden") except Exception as e: error_msg = f"Fehler beim Bearbeiten des TreeNode: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _collect_xsl_files_recursive(self, node): """ Sammelt rekursiv alle XslFile-Objekte unter einem TreeNode. Args: node: Der TreeNode, dessen XslFiles gesammelt werden sollen Returns: list[XslFile]: Liste aller XslFiles im Teilbaum """ xsl_files = [] for child in node.children: if isinstance(child, XslFile): xsl_files.append(child) elif isinstance(child, TreeNode): xsl_files.extend(self._collect_xsl_files_recursive(child)) return xsl_files def _count_sub_nodes_recursive(self, node): """ Zählt rekursiv alle untergeordneten TreeNodes. Args: node: Der TreeNode, dessen Unterknoten gezählt werden sollen Returns: int: Anzahl der untergeordneten TreeNodes """ count = 0 for child in node.children: if isinstance(child, TreeNode): count += 1 + self._count_sub_nodes_recursive(child) return count def _delete_tree_node(self, item): """ Löscht einen TreeNode und alle untergeordneten Elemente. Die XSL-Dateien werden nur aus dem Baum entfernt, nicht physisch gelöscht. Zugehörige PDF-Dateien werden automatisch bereinigt. XML-Dateien, die nirgends mehr verwendet werden, können optional physisch gelöscht werden. Args: item: Das TreeWidgetItem des TreeNodes """ logger.debug(f"TreeNode löschen: {item.text(0)}") try: # Prüfe ob ein Projekt geladen ist 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 # Hole das TreeNode-Objekt aus dem TreeWidgetItem tree_node_obj = item.data(0, Qt.ItemDataRole.UserRole) if not tree_node_obj or not isinstance(tree_node_obj, TreeNode): QMessageBox.warning(self, "Warnung", "Kein gültiger Baumknoten gefunden.") return # Sammle alle XslFiles im Teilbaum vor der Löschung xsl_files = self._collect_xsl_files_recursive(tree_node_obj) sub_node_count = self._count_sub_nodes_recursive(tree_node_obj) # Bestätigungsdialog details = [] if sub_node_count > 0: details.append(f"{sub_node_count} Unterknoten") if xsl_files: details.append(f"{len(xsl_files)} XSL-Datei(en)") detail_text = f"\n\nEnthaltene Elemente: {', '.join(details)}." if details else "" reply = QMessageBox.question( self, "Knoten löschen", f"Möchten Sie den Knoten '{tree_node_obj.bez}' und alle untergeordneten " f"Elemente aus dem Baum entfernen?\n\n" f"XSL-Dateien werden nicht physisch gelöscht.{detail_text}", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: logger.debug("Löschung abgebrochen") return # Ermittle den Eltern-Kontext und entferne den TreeNode aus dem Datenmodell # WICHTIG: Zuerst entfernen, damit _is_xml_xsl_combination_used_elsewhere() # und _is_xml_file_used_elsewhere() den gelöschten Teilbaum nicht mehr sehen parent_item = item.parent() if parent_item: parent_node = parent_item.data(0, Qt.ItemDataRole.UserRole) if not parent_node or not isinstance(parent_node, TreeNode): QMessageBox.warning(self, "Warnung", "Kein gültiger Eltern-Knoten gefunden.") return children_before = len(parent_node.children) parent_node.children = [child for child in parent_node.children if child is not tree_node_obj] if len(parent_node.children) == children_before: QMessageBox.warning(self, "Warnung", "Knoten konnte nicht aus dem Eltern-Knoten entfernt werden.") return else: # Top-Level-Knoten nodes_before = len(self.pdf_project.nodes) self.pdf_project.nodes = [node for node in self.pdf_project.nodes if node is not tree_node_obj] if len(self.pdf_project.nodes) == nodes_before: QMessageBox.warning(self, "Warnung", "Knoten konnte nicht aus dem Projekt entfernt werden.") return # PDF-Bereinigung für alle gesammelten XslFiles total_deleted_pdfs = 0 for xsl_file_obj in xsl_files: xsl_id = xsl_file_obj.id for xml_file_obj in xsl_file_obj.xmls: is_combination_used = self._is_xml_xsl_combination_used_elsewhere( xml_file_obj.xml, xsl_id, xsl_file_obj ) if not is_combination_used: deleted_pdfs = self._delete_pdf_files_for_xml_xsl_combination(xml_file_obj.xml, xsl_id) total_deleted_pdfs += deleted_pdfs if total_deleted_pdfs > 0: logger.info(f"{total_deleted_pdfs} PDF-Datei(en) für Knoten '{tree_node_obj.bez}' gelöscht") # Sammle XML-Dateien, die nirgends mehr verwendet werden unused_xml_files = [] seen_xml_paths = set() for xsl_file_obj in xsl_files: for xml_file_obj in xsl_file_obj.xmls: xml_path_str = str(xml_file_obj.xml) if xml_path_str in seen_xml_paths: continue seen_xml_paths.add(xml_path_str) xml_file_path = Path(self.project.project_dir) / xml_file_obj.xml if xml_file_path.exists(): is_used = self._is_xml_file_used_elsewhere(xml_file_obj.xml, xsl_file_obj) if not is_used: unused_xml_files.append((xml_file_obj, xml_file_path)) # Nicht mehr verwendete XML-Dateien physisch löschen for xml_file_obj, xml_file_path in unused_xml_files: try: xml_file_path.unlink() logger.info(f"Physische XML-Datei gelöscht: {xml_file_path}") except Exception as e: logger.warning(f"Konnte XML-Datei nicht löschen: {xml_file_path} - {e}") # Speichere und aktualisiere self._save_project_settings() self._load_nodes_to_tree() logger.info(f"TreeNode '{tree_node_obj.bez}' erfolgreich entfernt") except Exception as e: error_msg = f"Fehler beim Löschen des Knotens: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) # Kontextmenü-Aktionen für XslFile def _add_xml_file_to_xsl(self, parent_item): """ Fügt eine XML-Datei zu einer XSL-Datei hinzu. Args: parent_item: Das TreeWidgetItem des XslFile-Nodes """ logger.debug(f"XML-Datei zu XslFile hinzufügen: {parent_item.text(0)}") try: # Prüfe ob ein Projekt geladen ist 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 # Hole das XslFile-Node-Objekt direkt aus dem TreeWidgetItem xsl_node = parent_item.data(0, Qt.ItemDataRole.UserRole) if not xsl_node or not isinstance(xsl_node, XslFile): QMessageBox.warning(self, "Warnung", "Keine gültige XSL-Datei-Node gefunden.") return # Öffne Datei-Dialog zum Auswählen der XML-Datei xml_file_path, _ = QFileDialog.getOpenFileName( self, "XML-Datei auswählen", "", "XML-Dateien (*.xml);;Alle Dateien (*)" ) if not xml_file_path: # Benutzer hat abgebrochen return xml_file_path = Path(xml_file_path) # Prüfe ob die Datei existiert if not xml_file_path.exists(): QMessageBox.critical(self, "Fehler", f"Die ausgewählte XML-Datei existiert nicht:\n{xml_file_path}") return # Erstelle xml-Ordner im Projekt-Verzeichnis falls er nicht existiert xml_dir = Path(self.project.project_dir) / "xml" xml_dir.mkdir(parents=True, exist_ok=True) # Bestimme den Ziel-Pfad in xml-Ordner target_xml_path = xml_dir / xml_file_path.name # Prüfe ob eine Datei mit gleichem Namen bereits existiert if target_xml_path.exists(): reply = QMessageBox.question( self, "Datei existiert bereits", f"Eine XML-Datei mit dem Namen '{xml_file_path.name}' existiert bereits im xml-Ordner.\n\n" "Möchten Sie sie überschreiben?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return # Kopiere die XML-Datei in den xml-Ordner shutil.copy2(xml_file_path, target_xml_path) logger.info(f"XML-Datei kopiert: {xml_file_path} -> {target_xml_path}") # Erstelle relatives Path zur XML-Datei (relativ zum xml-Ordner) relative_xml_path = Path("xml") / xml_file_path.name # Prüfe ob diese XML-Datei bereits in der XslFile-Node vorhanden ist existing_xml = None for xml_file in xsl_node.xmls: if xml_file.xml == relative_xml_path: existing_xml = xml_file break if existing_xml: QMessageBox.information( self, "XML-Datei bereits vorhanden", f"Die XML-Datei '{xml_file_path.name}' ist bereits in dieser XSL-Datei enthalten.", ) return # Erstelle neues XmlFile-Objekt und füge es zur XslFile-Node hinzu new_xml_file = XmlFile(xml=relative_xml_path) xsl_node.xmls.append(new_xml_file) logger.info(f"XML-Datei '{xml_file_path.name}' zu XslFile-Node '{xsl_node.bez}' hinzugefügt") # Berechne Hash für die neue XML-Datei self._calculate_hash_for_xml_file(new_xml_file) # Speichere die aktualisierten Projekt-Einstellungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() except Exception as e: error_msg = f"Fehler beim Hinzufügen der XML-Datei: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _edit_xsl_file(self, item): """ Bearbeitet eine XSL-Datei. Args: item: Das TreeWidgetItem des XslFile """ logger.debug(f"XslFile bearbeiten: {item.text(0)}") try: # Hole das Node-Objekt aus dem TreeWidgetItem node = item.data(0, Qt.ItemDataRole.UserRole) if not node or not isinstance(node, XslFile): QMessageBox.warning(self, "Warnung", "Keine gültige XSL-Datei gefunden.") return # Sammle Eltern-Parameter parent_params = self._collect_parent_params(item) # Erstelle und zeige den Dialog dialog = XslFileEditDialog(self, node, parent_params) if dialog.exec() == XslFileEditDialog.DialogCode.Accepted: # Hole die bearbeiteten Daten data = dialog.get_data() if data: # Aktualisiere den Node node.bez = data["bez"] node.xslt_params = data["xslt_params"] logger.info(f"XslFile '{node.bez}' wurde aktualisiert") logger.debug(f"XSLT-Parameter: {node.xslt_params}") # Speichere die Änderungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() # Wenn Force-Transformation gewünscht, führe sie aus if data.get("force_transform", False): # Finde das neue Item nach dem Neuladen new_item = self._find_item_by_node(node) if new_item: logger.info(f"Starte Force-Transformation für XslFile '{node.bez}'") self._transform_xsl_file(new_item, force=True) else: logger.warning(f"Konnte Item für XslFile '{node.bez}' nicht finden") except Exception as e: error_msg = f"Fehler beim Bearbeiten der XSL-Datei: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _delete_xsl_file(self, item): """ Löscht eine XSL-Datei aus einem Baumknoten. Die XSL-Datei wird nur aus dem Baum entfernt, nicht physisch gelöscht. Zugehörige PDF-Dateien werden automatisch bereinigt. XML-Dateien, die nirgends mehr verwendet werden, können optional physisch gelöscht werden. Args: item: Das TreeWidgetItem der XSL-Datei """ logger.debug(f"XslFile löschen: {item.text(0)}") try: # Prüfe ob ein Projekt geladen ist 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 # Hole das XslFile-Objekt aus dem TreeWidgetItem xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole) if not xsl_file_obj or not isinstance(xsl_file_obj, XslFile): QMessageBox.warning(self, "Warnung", "Keine gültige XSL-Datei gefunden.") return # Hole das Eltern-Item (sollte ein TreeNode sein) parent_item = item.parent() if not parent_item: QMessageBox.warning(self, "Warnung", "Eltern-Knoten nicht gefunden.") return parent_node = parent_item.data(0, Qt.ItemDataRole.UserRole) if not parent_node or not isinstance(parent_node, TreeNode): QMessageBox.warning(self, "Warnung", "Kein gültiger Eltern-Knoten gefunden.") return # Bestätigungsdialog anzeigen xml_count = len(xsl_file_obj.xmls) xml_info = f"\n\nDer XSL-Datei sind {xml_count} XML-Datei(en) zugeordnet." if xml_count > 0 else "" reply = QMessageBox.question( self, "XSL-Datei entfernen", f"Möchten Sie die XSL-Datei '{xsl_file_obj.bez}' aus dem Baum entfernen?\n\n" f"Die XSL-Datei selbst wird nicht physisch gelöscht.{xml_info}", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: logger.debug("Löschung abgebrochen") return # PDF-Bereinigung für alle zugehörigen XML-Dateien xsl_id = xsl_file_obj.id total_deleted_pdfs = 0 for xml_file_obj in xsl_file_obj.xmls: is_combination_used = self._is_xml_xsl_combination_used_elsewhere( xml_file_obj.xml, xsl_id, xsl_file_obj ) if not is_combination_used: deleted_pdfs = self._delete_pdf_files_for_xml_xsl_combination(xml_file_obj.xml, xsl_id) total_deleted_pdfs += deleted_pdfs if total_deleted_pdfs > 0: logger.info(f"{total_deleted_pdfs} PDF-Datei(en) für XSL '{xsl_file_obj.bez}' gelöscht") # Sammle XML-Dateien, die nirgends mehr verwendet werden unused_xml_files = [] for xml_file_obj in xsl_file_obj.xmls: xml_file_path = Path(self.project.project_dir) / xml_file_obj.xml if xml_file_path.exists(): is_used = self._is_xml_file_used_elsewhere(xml_file_obj.xml, xsl_file_obj) if not is_used: unused_xml_files.append((xml_file_obj, xml_file_path)) # Optionale physische Löschung nicht mehr verwendeter XML-Dateien if unused_xml_files: file_list = "\n".join(f" - {p.name}" for _, p in unused_xml_files) delete_reply = QMessageBox.question( self, "Physische XML-Dateien löschen", f"Folgende {len(unused_xml_files)} XML-Datei(en) werden in keiner anderen " f"XSL-Datei mehr verwendet:\n\n{file_list}\n\n" "Möchten Sie diese physisch löschen?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if delete_reply == QMessageBox.StandardButton.Yes: for xml_file_obj, xml_file_path in unused_xml_files: try: xml_file_path.unlink() logger.info(f"Physische XML-Datei gelöscht: {xml_file_path}") except Exception as e: logger.warning(f"Konnte XML-Datei nicht löschen: {xml_file_path} - {e}") # Entferne das XslFile aus dem Eltern-TreeNode children_before = len(parent_node.children) parent_node.children = [child for child in parent_node.children if child is not xsl_file_obj] children_after = len(parent_node.children) if children_before == children_after: QMessageBox.warning(self, "Warnung", "XSL-Datei konnte nicht aus dem Knoten entfernt werden.") return logger.info(f"XSL-Datei '{xsl_file_obj.bez}' aus Knoten '{parent_node.bez}' entfernt") # Speichere und aktualisiere self._save_project_settings() self._load_nodes_to_tree() logger.info(f"XSL-Datei '{xsl_file_obj.bez}' erfolgreich entfernt") except Exception as e: error_msg = f"Fehler beim Löschen der XSL-Datei: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _get_xsl_abs_path(self, xsl_file_obj: XslFile) -> Path | None: """ Ermittelt den absoluten Pfad einer XSL-Datei anhand der Projekt-Konfiguration. Args: xsl_file_obj: Das XslFile-Objekt Returns: Absoluter Pfad oder None wenn nicht ermittelbar """ if not hasattr(self, "project") or not self.project: return None from conf import app_settings xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None) if not xsl_dir: return None return xsl_dir.path_to_root_dir / xsl_file_obj.xsl_file def _ensure_xsl_dependency_graph(self): """Stellt sicher, dass der XSL-Abhängigkeitsgraph initialisiert ist.""" if not hasattr(self, "xsl_dependency_graph") or self.xsl_dependency_graph is None: from xsl_dependencies import XslDependencyGraph self.xsl_dependency_graph = XslDependencyGraph() def _set_xsl_dependency_tooltip(self, item: QTreeWidgetItem, xsl_file_abs: Path): """ Setzt einen Tooltip mit den Abhängigkeiten (import/include) einer XSL-Datei. Args: item: Das TreeWidgetItem der XSL-Datei xsl_file_abs: Absoluter Pfad zur XSL-Datei """ self._ensure_xsl_dependency_graph() deps = self.xsl_dependency_graph.get_dependencies(xsl_file_abs) if not deps: item.setToolTip(0, f"{xsl_file_abs.name}\nKeine Abhängigkeiten (import/include)") return # Sortierte Liste der Abhängigkeiten (nur Dateinamen) dep_names = sorted(dep.name for dep in deps) dep_list = "\n".join(f" - {name}" for name in dep_names) tooltip = f"{xsl_file_abs.name}\n{len(deps)} Abhängigkeit(en):\n{dep_list}" item.setToolTip(0, tooltip) def _show_xsl_dependencies(self, item: QTreeWidgetItem): """ Zeigt einen Dialog mit den Abhängigkeiten einer XSL-Datei. Args: item: Das TreeWidgetItem der XSL-Datei """ xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole) if not isinstance(xsl_file_obj, XslFile): return xsl_file_abs = self._get_xsl_abs_path(xsl_file_obj) if not xsl_file_abs or not xsl_file_abs.exists(): QMessageBox.warning(self, "Fehler", f"XSL-Datei nicht gefunden: {xsl_file_abs}") return self._ensure_xsl_dependency_graph() deps = self.xsl_dependency_graph.get_dependencies(xsl_file_abs) if not deps: QMessageBox.information( self, f"Abhängigkeiten: {xsl_file_obj.bez}", f"Die XSL-Datei '{xsl_file_abs.name}' hat keine Abhängigkeiten (import/include).", ) return # Sortierte Liste mit relativen Pfaden (relativ zum XSL-Verzeichnis) xsl_root = xsl_file_abs.parent from conf import app_settings xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None) if xsl_dir: xsl_root = xsl_dir.path_to_root_dir dep_lines = [] for dep in sorted(deps, key=lambda p: p.name): try: rel_path = dep.relative_to(xsl_root) except ValueError: rel_path = dep.name dep_lines.append(f" - {rel_path}") dep_text = "\n".join(dep_lines) QMessageBox.information( self, f"Abhängigkeiten: {xsl_file_obj.bez}", f"Die XSL-Datei '{xsl_file_abs.name}' importiert/inkludiert {len(deps)} Datei(en):\n\n{dep_text}", ) # Kontextmenü-Aktionen für XmlFile def _edit_xml_file(self, item): """ 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)}") 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): """ Löscht eine XML-Datei aus einem XSL-Knoten. Args: item: Das TreeWidgetItem der XML-Datei """ logger.debug(f"XmlFile löschen: {item.text(0)}") try: # Prüfe ob ein Projekt geladen ist 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 # Hole das XmlFile-Objekt aus dem TreeWidgetItem 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 # Hole das Eltern-Item (sollte ein XslFile sein) parent_item = item.parent() if not parent_item: QMessageBox.warning(self, "Warnung", "Eltern-XSL-Datei nicht gefunden.") return # Hole das XslFile-Objekt aus dem Eltern-Item xsl_file_obj = parent_item.data(0, Qt.ItemDataRole.UserRole) if not xsl_file_obj or not isinstance(xsl_file_obj, XslFile): QMessageBox.warning(self, "Warnung", "Keine gültige Eltern-XSL-Datei gefunden.") return # Bestätigungsdialog anzeigen xml_filename = xml_file_obj.xml.name reply = QMessageBox.question( self, "XML-Datei löschen", f"Möchten Sie die XML-Datei '{xml_filename}' aus der XSL-Datei '{xsl_file_obj.bez}' entfernen?\n\n" "Die XML-Datei wird nur aus der Zuordnung entfernt, nicht physisch gelöscht.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: logger.debug("Löschung abgebrochen") return # Entferne die XML-Datei aus der XslFile-Node xml_files_before = len(xsl_file_obj.xmls) xsl_file_obj.xmls = [xml for xml in xsl_file_obj.xmls if xml.xml != xml_file_obj.xml] xml_files_after = len(xsl_file_obj.xmls) if xml_files_before == xml_files_after: QMessageBox.warning(self, "Warnung", "XML-Datei konnte nicht aus der XSL-Datei entfernt werden.") return logger.info(f"XML-Datei '{xml_filename}' aus XSL-Datei '{xsl_file_obj.bez}' entfernt") # Prüfe ob die XML+XSL-Kombination noch woanders verwendet wird # Wenn nicht, lösche die zugehörigen PDF-Dateien xsl_id = xsl_file_obj.id is_combination_used_elsewhere = self._is_xml_xsl_combination_used_elsewhere( xml_file_obj.xml, xsl_id, xsl_file_obj ) if not is_combination_used_elsewhere: # Lösche alle PDF-Dateien für diese XML+XSL-Kombination deleted_pdfs = self._delete_pdf_files_for_xml_xsl_combination(xml_file_obj.xml, xsl_id) if deleted_pdfs > 0: logger.info(f"{deleted_pdfs} PDF-Datei(en) für '{xml_filename}' (XSL: {xsl_file_obj.bez}) gelöscht") else: logger.debug( f"XML+XSL-Kombination '{xml_filename}' + XSL-ID {xsl_id} wird noch anderswo verwendet - PDFs nicht gelöscht" ) # Frage ob die physische Datei auch gelöscht werden soll xml_file_path = Path(self.project.project_dir) / xml_file_obj.xml if xml_file_path.exists(): # Prüfe ob die XML-Datei noch in anderen XSL-Dateien verwendet wird is_used_elsewhere = self._is_xml_file_used_elsewhere(xml_file_obj.xml, xsl_file_obj) if not is_used_elsewhere: delete_reply = QMessageBox.question( self, "Physische Datei löschen", f"Die XML-Datei '{xml_filename}' wird in keiner anderen XSL-Datei verwendet.\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 delete_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)}") else: logger.info( f"XML-Datei '{xml_filename}' wird noch in anderen XSL-Dateien verwendet - physische Datei nicht gelöscht" ) # Speichere die aktualisierten Projekt-Einstellungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() logger.info(f"XML-Datei '{xml_filename}' erfolgreich entfernt") except Exception as e: error_msg = f"Fehler beim Löschen der XML-Datei: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _is_xml_file_used_elsewhere(self, xml_path, exclude_xsl_file): """ Prüft ob eine XML-Datei noch in anderen XSL-Dateien verwendet wird. Args: xml_path: Pfad zur XML-Datei (relativ) exclude_xsl_file: XSL-Datei die ausgeschlossen werden soll Returns: bool: True wenn die XML-Datei noch anderswo verwendet wird """ try: # Prüfe ob pdf_project und nodes existieren if not self.pdf_project or not self.pdf_project.nodes: return False # Keine Nodes vorhanden, also nicht verwendet return self._check_xml_usage_recursive(self.pdf_project.nodes, xml_path, exclude_xsl_file) except Exception as e: logger.error(f"Fehler beim Prüfen der XML-Datei-Verwendung: {e}") return True # Im Zweifelsfall annehmen, dass sie verwendet wird def _check_xml_usage_recursive(self, nodes, xml_path, exclude_xsl_file): """ Prüft rekursiv ob eine XML-Datei in den Nodes verwendet wird. Args: nodes: Liste der zu prüfenden Nodes xml_path: Pfad zur XML-Datei (relativ) exclude_xsl_file: XSL-Datei die ausgeschlossen werden soll Returns: bool: True wenn die XML-Datei gefunden wird """ for node in nodes: if isinstance(node, XslFile) and node != exclude_xsl_file: # Prüfe ob diese XSL-Datei die XML-Datei verwendet for xml_file in node.xmls: if xml_file.xml == xml_path: return True elif isinstance(node, TreeNode) and node.children: # Rekursiv in Knoten suchen if self._check_xml_usage_recursive(node.children, xml_path, exclude_xsl_file): return True return False def _is_xml_xsl_combination_used_elsewhere(self, xml_path: Path, xsl_id: tuple, exclude_xsl_file) -> bool: """ Prüft ob eine XML+XSL-Kombination noch in anderen XSL-Dateien verwendet wird. Args: xml_path: Pfad zur XML-Datei (relativ) xsl_id: Tuple der XSL-ID exclude_xsl_file: XSL-Datei die ausgeschlossen werden soll Returns: bool: True wenn die Kombination noch anderswo verwendet wird """ try: if not self.pdf_project or not self.pdf_project.nodes: return False return self._check_xml_xsl_combination_recursive(self.pdf_project.nodes, xml_path, xsl_id, exclude_xsl_file) except Exception as e: logger.error(f"Fehler beim Prüfen der XML+XSL-Kombination: {e}") return True # Im Zweifelsfall annehmen, dass sie verwendet wird def _check_xml_xsl_combination_recursive(self, nodes, xml_path: Path, xsl_id: tuple, exclude_xsl_file) -> bool: """ Prüft rekursiv ob eine XML+XSL-Kombination in den Nodes verwendet wird. Args: nodes: Liste der zu prüfenden Nodes xml_path: Pfad zur XML-Datei (relativ) xsl_id: Tuple der XSL-ID exclude_xsl_file: XSL-Datei die ausgeschlossen werden soll Returns: bool: True wenn die Kombination gefunden wird """ for node in nodes: if isinstance(node, XslFile) and node != exclude_xsl_file: # Prüfe ob diese XSL-Datei die gleiche ID hat UND die XML-Datei verwendet if node.id == xsl_id: for xml_file in node.xmls: if xml_file.xml == xml_path: return True elif isinstance(node, TreeNode) and node.children: # Rekursiv in Knoten suchen if self._check_xml_xsl_combination_recursive(node.children, xml_path, xsl_id, exclude_xsl_file): return True return False def _delete_pdf_files_for_xml_xsl_combination(self, xml_path: Path, xsl_id: tuple) -> int: """ Löscht alle PDF-Dateien (new, ref, diff) für eine XML+XSL-Kombination. Args: xml_path: Pfad zur XML-Datei (relativ) xsl_id: Tuple der XSL-ID Returns: int: Anzahl der gelöschten PDF-Dateien """ deleted_count = 0 try: # Erzeuge den PDF-Dateinamen basierend auf XML und XSL-ID base_name = xml_path.stem xsl_id_str = "_".join(str(x) for x in xsl_id) pdf_filename = f"{base_name}_xsl_{xsl_id_str}.pdf" # Lösche PDFs in allen drei Verzeichnissen pdf_dirs = ["new", "ref", "diff"] for dir_name in pdf_dirs: pdf_path = self.project.project_dir / dir_name / pdf_filename if pdf_path.exists(): try: pdf_path.unlink() logger.info(f"PDF-Datei gelöscht: {pdf_path}") deleted_count += 1 except Exception as e: logger.warning(f"Konnte PDF-Datei nicht löschen: {pdf_path} - {e}") if deleted_count > 0: logger.info(f"{deleted_count} PDF-Datei(en) für {xml_path.name} (XSL-ID: {xsl_id}) gelöscht") else: logger.debug(f"Keine PDF-Dateien für {xml_path.name} (XSL-ID: {xsl_id}) gefunden") except Exception as e: logger.error(f"Fehler beim Löschen der PDF-Dateien: {e}") return deleted_count # Kontextmenü-Aktionen für Root-Elemente (Unbekannter Typ) def _add_root_tree_node(self): """Fügt einen neuen TreeNode als Root-Element hinzu.""" logger.debug("Neuen TreeNode als Root-Element hinzufügen") # TODO: Dialog zum Eingeben der TreeNode-Daten öffnen def _collect_parent_params(self, item): """ Sammelt die XSLT-Parameter aller Eltern-Nodes von der Wurzel bis zum angegebenen Item. Parameter werden von oben nach unten gesammelt, wobei tiefere Ebenen höhere Priorität haben. Args: item: Das TreeWidgetItem (kann TreeNode oder XslFile sein) Returns: dict: Dictionary mit allen gesammelten Parametern (tiefere Ebenen überschreiben höhere) """ parent_params = {} try: # 1. Projektweite Parameter als Basis (niedrigste Priorität) if hasattr(self, "project") and self.project and self.project.xslt_params: parent_params.update(self.project.xslt_params) # 2. Sammle alle Eltern-Items in einer Liste (von unten nach oben) parents = [] current_item = item.parent() while current_item: parents.append(current_item) current_item = current_item.parent() # Kehre Liste um, sodass wir von Wurzel zu Kind iterieren parents.reverse() # Sammle Parameter von Wurzel zu Kind (Kind überschreibt Eltern) for parent_item in parents: parent_node = parent_item.data(0, Qt.ItemDataRole.UserRole) if parent_node and hasattr(parent_node, "xslt_params") and parent_node.xslt_params: # Update überschreibt vorherige Werte (höhere Priorität für tiefere Ebenen) parent_params.update(parent_node.xslt_params) logger.debug(f"Gesammelte Eltern-Parameter: {parent_params}") return parent_params except Exception as e: logger.error(f"Fehler beim Sammeln der Eltern-Parameter: {e}") return {} def _save_project_settings(self): """ Speichert die aktualisierten Projekt-Einstellungen. """ try: # Prüfe ob pdf_project und project existieren if not self.pdf_project: logger.warning("Keine Projekt-Einstellungen zum Speichern verfügbar") return if not self.project or not self.project.project_dir: logger.warning("Kein Projekt-Verzeichnis zum Speichern verfügbar") return start_time = time.time() # Expand-Status nur speichern, wenn kein Suchfilter aktiv ist # (sonst werden die durch die Suche erzwungenen Expansionen gespeichert) if not self.ui.searchEdit.text().strip(): self._save_expanded_state() # Speichere in project.yaml im Projekt-Verzeichnis self.pdf_project.writeSettings(project_dir=self.project.project_dir) dump_time = time.time() - start_time logger.debug(f"Performance: Projekt-Einstellungen gespeichert in {dump_time:.3f}s") except Exception as e: logger.error(f"Fehler beim Speichern der Projekt-Einstellungen: {e}") raise def _save_expanded_state(self): """ Speichert die IDs aller aufgeklappten Knoten (TreeNode und XslFile) in den Projekteinstellungen. """ if not hasattr(self, "pdf_project") or not self.pdf_project: logger.warning("Keine Projekt-Einstellungen zum Speichern des Expand-Status") return try: expanded_node_ids = [] # Durchlaufe alle Top-Level-Items root_count = self.ui.treeWidget.topLevelItemCount() for i in range(root_count): item = self.ui.treeWidget.topLevelItem(i) self._collect_expanded_items(item, expanded_node_ids) # Speichere in Projekteinstellungen self.pdf_project.expanded_nodes = expanded_node_ids logger.info(f"Expand-Status gespeichert: {len(expanded_node_ids)} aufgeklappte Knoten") logger.debug(f"Aufgeklappte Knoten-IDs: {expanded_node_ids}") except Exception as e: logger.error(f"Fehler beim Speichern des Expand-Status: {e}") def _collect_expanded_items(self, item: QTreeWidgetItem, expanded_ids: list): """ Sammelt rekursiv die IDs aller aufgeklappten Items. Args: item: Das zu prüfende TreeWidgetItem expanded_ids: Liste zum Sammeln der IDs """ # Hole Node-Objekt node = item.data(0, Qt.ItemDataRole.UserRole) if not node: return # Wenn Item aufgeklappt ist, speichere ID if item.isExpanded() and hasattr(node, "id"): expanded_ids.append(node.id) # Rekursiv alle Kinder durchlaufen child_count = item.childCount() for i in range(child_count): child_item = item.child(i) self._collect_expanded_items(child_item, expanded_ids) def _restore_expanded_state(self): """ Stellt den Expand-Status aller Knoten aus den Projekteinstellungen wieder her. """ if not hasattr(self, "pdf_project") or not self.pdf_project: logger.warning("Keine Projekt-Einstellungen zum Wiederherstellen des Expand-Status") return if not self.pdf_project.expanded_nodes: logger.debug("Keine gespeicherten Expand-Status-Informationen vorhanden") return try: expanded_node_ids = set(self.pdf_project.expanded_nodes) logger.info(f"Stelle Expand-Status wieder her: {len(expanded_node_ids)} Knoten") logger.debug(f"Aufzuklappende Knoten-IDs: {list(expanded_node_ids)}") # Durchlaufe alle Top-Level-Items root_count = self.ui.treeWidget.topLevelItemCount() for i in range(root_count): item = self.ui.treeWidget.topLevelItem(i) self._expand_items_by_id(item, expanded_node_ids) except Exception as e: logger.error(f"Fehler beim Wiederherstellen des Expand-Status: {e}") def _expand_items_by_id(self, item: QTreeWidgetItem, expanded_ids: set): """ Klappt Items rekursiv auf, wenn ihre ID in expanded_ids enthalten ist. Args: item: Das zu prüfende TreeWidgetItem expanded_ids: Set der IDs, die aufgeklappt werden sollen """ # Hole Node-Objekt node = item.data(0, Qt.ItemDataRole.UserRole) if not node or not hasattr(node, "id"): return # Wenn ID in der Liste ist, klappe Item auf if node.id in expanded_ids: item.setExpanded(True) # Rekursiv alle Kinder durchlaufen child_count = item.childCount() for i in range(child_count): child_item = item.child(i) self._expand_items_by_id(child_item, expanded_ids)