import glob import os import time import polars as pl import shutil import hashlib import logging from typing import List from PySide6.QtCore import Qt, QSize, QThread, Signal from PySide6.QtGui import QCursor, QPixmap, QPainter, QAction, QIcon, QDragEnterEvent, QDropEvent from PySide6.QtWidgets import ( QLabel, QMainWindow, QApplication, QStyleFactory, QMenu, QTreeWidgetItem, QMessageBox, QFileDialog, QWidget, QHBoxLayout, QProgressBar, ) from PySide6.QtPdf import QPdfDocument from ui.MainWinddow_ui import Ui_MainWindow from ui.AppSettings import AppSettingsDlg from ui.PdfProject import PdfProjectDlg from ui.TreeNodeEditDialog import TreeNodeEditDialog from ui.XslFileEditDialog import XslFileEditDialog from ui.XmlToXslAssignDialog import XmlToXslAssignDialog from conf import app_settings, Project, ProjectData, TreeNode, XslFile, XmlFile from transform import TransformationJob from pathlib import Path logger = logging.getLogger(__name__) class XmlHashCalculatorThread(QThread): """ Thread für die asynchrone Berechnung von blake2b-Hash-Werten für XML-Dateien. """ # Signale für die Kommunikation mit dem Haupt-Thread hash_calculated = Signal(object, str) # xml_file_object, hash_value calculation_finished = Signal(int, int) # processed_count, total_count error_occurred = Signal(str, str) # xml_file_path, error_message def __init__(self, project_dir: Path, xml_files: List[XmlFile]): """ Initialisiert den Hash-Berechnungs-Thread. Args: project_dir: Pfad zum Projekt-Verzeichnis xml_files: Liste der XmlFile-Objekte für die Hash-Berechnung """ super().__init__() self.project_dir = project_dir self.xml_files = xml_files self.processed_count = 0 def run(self): """ Führt die Hash-Berechnung für alle XML-Dateien aus. """ logger.info(f"Starte Hash-Berechnung für {len(self.xml_files)} XML-Dateien") for xml_file in self.xml_files: try: # Prüfe ob hashsum bereits vorhanden ist if xml_file.hashsum: logger.debug(f"Hash bereits vorhanden für {xml_file.xml}: {xml_file.hashsum}") self.processed_count += 1 continue # Berechne Hash für die XML-Datei xml_file_path = self.project_dir / xml_file.xml hash_value = self._calculate_blake2b_hash(xml_file_path) if hash_value: # Sende Signal mit berechnetem Hash self.hash_calculated.emit(xml_file, hash_value) logger.debug(f"Hash berechnet für {xml_file.xml}: {hash_value}") self.processed_count += 1 except Exception as e: error_msg = f"Fehler bei Hash-Berechnung für {xml_file.xml}: {str(e)}" logger.error(error_msg) self.error_occurred.emit(str(xml_file.xml), error_msg) self.processed_count += 1 # Sende Abschluss-Signal self.calculation_finished.emit(self.processed_count, len(self.xml_files)) logger.info(f"Hash-Berechnung abgeschlossen: {self.processed_count}/{len(self.xml_files)} verarbeitet") def _calculate_blake2b_hash(self, file_path: Path) -> str | None: """ Berechnet den blake2b-Hash einer XML-Datei. Args: file_path: Pfad zur XML-Datei Returns: str: Hash-Wert mit "blake2b:" Präfix oder None bei Fehler """ try: if not file_path.exists(): logger.warning(f"XML-Datei nicht gefunden: {file_path}") return None # Datei binär lesen und Hash berechnen with open(file_path, "rb") as f: file_content = f.read() hash_obj = hashlib.blake2b(file_content) hash_hex = hash_obj.hexdigest() # Präfix hinzufügen return f"blake2b:{hash_hex}" except Exception as e: logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}") return None class TransformationThread(QThread): """ Thread für die asynchrone Ausführung von Transformations-Jobs. """ # Signale für die Kommunikation mit dem Haupt-Thread job_started = Signal(str, str) # xml_file_name, xsl_id_str job_finished = Signal(dict) # result_dict job_error = Signal(str, str, str) # xml_file_name, xsl_id_str, error_message all_jobs_finished = Signal(int, int) # successful_count, total_count def __init__(self, jobs: list[TransformationJob], force: bool = False): """ Initialisiert den Transformations-Thread. Args: jobs: Liste der TransformationJob-Objekte force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date) """ super().__init__() self.jobs = jobs self.force = force self.successful_count = 0 def run(self): """ Führt alle Transformations-Jobs sequenziell aus. """ logger.info(f"Starte Transformation von {len(self.jobs)} Jobs") for job in self.jobs: try: # Sende Start-Signal mit XSL-ID xsl_id_str = "_".join(str(x) for x in job.xsl_id) if job.xsl_id else "" self.job_started.emit(str(job.xml_file), xsl_id_str) # Führe Transformations-Pipeline aus result = job.run_full_pipeline(force=self.force) # Sende Abschluss-Signal self.job_finished.emit(result) if result["success"]: self.successful_count += 1 except Exception as e: error_msg = f"Unerwarteter Fehler bei Transformation: {str(e)}" logger.error(error_msg) xsl_id_str = "_".join(str(x) for x in job.xsl_id) if job.xsl_id else "" self.job_error.emit(str(job.xml_file), xsl_id_str, error_msg) # Sende Abschluss-Signal für alle Jobs self.all_jobs_finished.emit(self.successful_count, len(self.jobs)) logger.info(f"Transformation abgeschlossen: {self.successful_count}/{len(self.jobs)} erfolgreich") class MainWindow(QMainWindow): def __init__(self, parent=None): """ Konstruktor für die MainWindow-Klasse. Verwendet PySide6.QtPdf für optimale Performance. Args: parent: Übergeordnetes Widget, falls vorhanden """ super().__init__(parent) # UI einrichten self.ui = Ui_MainWindow() self.ui.setupUi(self) # Dict zum Speichern der Beziehung zwischen Thumbnails und Seitennummern self.thumbnail_to_page = {} # PDF-Dokumente für späteres On-Demand-Rendering speichern self.pdf_documents = {} # {pdf_filename: {'diff': QPdfDocument, 'ref': QPdfDocument, 'new': QPdfDocument}} # Aktueller Zoom-Faktor self.current_zoom = 100 # 100% # Aktuell angezeigte Seite self.current_page = 0 self.current_pdf = None # Cache für die aktuell gerenderten Pixmaps (Performance-Optimierung) self.current_rendered_pixmaps = None # Label für die Vollansicht (nur ein einziges Label) self.fullsize_label = None # Variablen für Drag-to-Scroll (Anti-Jitter für 4K/DPI-Skalierung) self.is_dragging = False self.last_drag_position = None self.drag_threshold = 3 # Mindestbewegung in Pixeln vor dem Scrollen self.scroll_sensitivity = 0.7 # Reduzierte Empfindlichkeit für sanfteres Scrollen # Das aktuelle Projekt (Project) aus app_settings self.project = None # Das aktuelle ProjectData self.pdf_project = None # Hash-Berechnungs-Thread self.hash_calculator_thread = None # Transformations-Thread self.transformation_thread = None # Mapping: xml_file_path_str → QTreeWidgetItem (für Progress Bar und Icon Updates) self.xml_item_map = {} # Theme-Menü initialisieren self._setup_theme_menu() # Vorhandene Projekte-Menü initialisieren self._setup_projects_menu() # if theme := app_settings.theme: self.change_theme(theme) else: self.change_theme("Fusion") # Bilder laden self._load_images() # Signale und Slots verbinden self._connect_signals() # Kontextmenü für TreeWidget einrichten self._setup_tree_context_menu() # TreeWidget Styling für größeren vertikalen Abstand self._setup_tree_widget_styling() # Drag&Drop für TreeWidget aktivieren self._setup_drag_drop() def _setup_theme_menu(self): """Initialisiert das Theme-Menü mit verfügbaren Themes.""" # Hole alle verfügbaren Themes available_themes = QStyleFactory.keys() current_theme = QApplication.style().objectName() print(f"Verfügbare Themes: {available_themes}") print(f"Aktuelles Theme: {current_theme}") # Füge Theme-Aktionen zum Menü hinzu for theme_name in available_themes: action = QAction(theme_name, self) action.setCheckable(True) # Markiere das aktuelle Theme if theme_name.lower() == current_theme.lower(): action.setChecked(True) # Verbinde die Aktion mit der Theme-Wechsel-Funktion action.triggered.connect(lambda checked, theme=theme_name: self.change_theme(theme)) # Füge die Aktion zum Theme-Menü hinzu self.ui.menuThema.addAction(action) def _setup_projects_menu(self): """Initialisiert das Vorhandene Projekte-Menü mit gespeicherten Projekten.""" # Prüfe ob Projekte vorhanden sind if not app_settings.pdf_projects: # Keine Projekte vorhanden - Menü deaktiviert lassen self.ui.actionVorhandene_Projekte.setEnabled(False) self.ui.actionVorhandene_Projekte.setText("Vorhandene Projekte (keine vorhanden)") return # Projekte vorhanden - Menü aktivieren und Untermenü erstellen self.ui.actionVorhandene_Projekte.setEnabled(True) self.ui.actionVorhandene_Projekte.setText("Vorhandene Projekte") # Erstelle ein Untermenü für die Projekte projects_menu = QMenu(self) # Füge jedes Projekt als Menü-Eintrag hinzu for project in app_settings.pdf_projects: project_action = QAction(project.name, self) project_action.setToolTip(f"Projekt-Ordner: {project.project_dir}") # Verbinde die Aktion mit der Projekt-Öffnen-Funktion project_action.triggered.connect(lambda checked, proj=project: self.open_existing_project(proj)) projects_menu.addAction(project_action) # Setze das Untermenü für die Aktion self.ui.actionVorhandene_Projekte.setMenu(projects_menu) print(f"Projekte-Menü initialisiert mit {len(app_settings.pdf_projects)} Projekten") def open_existing_project(self, project: Project): """ Öffnet ein vorhandenes Projekt. Args: project: Das zu öffnende PdfProject-Objekt """ print(f"Öffne Projekt: {project.name}") print(f"Projekt-Ordner: {project.project_dir}") self.project = project try: # Prüfe ob project.yaml existiert und nicht leer ist project_yaml_path = Path(project.project_dir) / "project.yaml" if project_yaml_path.exists() and project_yaml_path.stat().st_size > 0: # Versuche die Projekt-Einstellungen zu laden self.pdf_project = ProjectData.readSettings(project_dir=project.project_dir) print(f"Projekt-Einstellungen aus {project_yaml_path} geladen!") else: # Erstelle Standard-Projekt-Einstellungen wenn Datei leer oder nicht vorhanden print("project.yaml ist leer oder nicht vorhanden, erstelle Standard-Einstellungen") self.pdf_project = ProjectData() # Speichere die Standard-Einstellungen in die project.yaml self.pdf_project.writeSettings(project_dir=project.project_dir) print(f"Standard-Projekt-Einstellungen in {project_yaml_path} gespeichert") # Lade die Nodes in das TreeWidget self._load_nodes_to_tree() # Starte Hash-Berechnung für alle XML-Dateien self._start_xml_hash_calculation() # Setze Icons für bereits existierende Diff-PDFs self._update_diff_icons_for_existing_pdfs() except Exception as e: print(f"Fehler beim Laden des Projekts '{project.name}': {e}") # Fallback: Erstelle Standard-Einstellungen try: self.pdf_project = ProjectData() print("Fallback: Standard-Projekt-Einstellungen erstellt") # Auch bei Fallback die Nodes laden self._load_nodes_to_tree() except Exception as fallback_error: print(f"Fehler beim Erstellen der Fallback-Einstellungen: {fallback_error}") def change_theme(self, theme_name): """ Wechselt das Theme der Anwendung. Args: theme_name: Name des zu verwendenden Themes """ print(f"Wechsle zu Theme: {theme_name}") try: # Erstelle den neuen Style style = QStyleFactory.create(theme_name) if style: # Wende den neuen Style auf die Anwendung an QApplication.setStyle(style) # Aktualisiere die Checkmarks im Menü for action in self.ui.menuThema.actions(): action.setChecked(action.text() == theme_name) print(f"Theme erfolgreich gewechselt zu: {theme_name}") app_settings.theme = theme_name app_settings.save() else: print(f"Fehler: Theme '{theme_name}' konnte nicht erstellt werden") except Exception as e: print(f"Fehler beim Wechseln des Themes: {e}") def _load_images(self): """Lädt PDF-Thumbnails und bereitet On-Demand-Rendering vor.""" # Entferne bestehende Widgets aus den Layouts self._clear_layout(self.ui.verticalLayout_2) self._clear_layout(self.ui.verticalLayout_3) # Dicts zurücksetzen self.thumbnail_to_page = {} self.pdf_documents = {} self.current_rendered_pixmaps = None # Basis-Pfad zu den PDF-Ordnern base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) pdf_base_dir = os.path.join(base_dir, "ui", "res", "pdf") diff_dir = os.path.join(pdf_base_dir, "diff") ref_dir = os.path.join(pdf_base_dir, "ref") new_dir = os.path.join(pdf_base_dir, "new") # Prüfe ob diff-Ordner existiert und PDF-Dateien enthält if not os.path.exists(diff_dir): print(f"Diff-Ordner nicht gefunden: {diff_dir}") return # Finde alle PDF-Dateien im diff-Ordner diff_pdfs = glob.glob(os.path.join(diff_dir, "*.pdf")) if not diff_pdfs: print("Keine PDF-Dateien im diff-Ordner gefunden") return print(f"Gefundene PDF-Dateien im diff-Ordner: {diff_pdfs}") # Für jede PDF-Datei im diff-Ordner for diff_pdf_path in diff_pdfs: pdf_filename = os.path.basename(diff_pdf_path) ref_pdf_path = os.path.join(ref_dir, pdf_filename) new_pdf_path = os.path.join(new_dir, pdf_filename) # Prüfe ob gleichnamige PDFs in ref und new existieren if not os.path.exists(ref_pdf_path): print(f"Referenz-PDF nicht gefunden: {ref_pdf_path}") continue if not os.path.exists(new_pdf_path): print(f"New-PDF nicht gefunden: {new_pdf_path}") continue try: # Alle drei PDF-Dateien öffnen mit QtPdf diff_doc = QPdfDocument() ref_doc = QPdfDocument() new_doc = QPdfDocument() # PDF-Dateien laden diff_doc.load(diff_pdf_path) ref_doc.load(ref_pdf_path) new_doc.load(new_pdf_path) # Warten bis PDFs geladen sind if ( diff_doc.status() != QPdfDocument.Status.Ready or ref_doc.status() != QPdfDocument.Status.Ready or new_doc.status() != QPdfDocument.Status.Ready ): print(f"Fehler beim Laden der PDFs für {pdf_filename}") continue # PDF-Dokumente für später speichern self.pdf_documents[pdf_filename] = {"diff": diff_doc, "ref": ref_doc, "new": new_doc} print(f"PDFs geladen: {pdf_filename}") print(f" diff: {diff_doc.pageCount()} Seiten") print(f" ref: {ref_doc.pageCount()} Seiten") print(f" new: {new_doc.pageCount()} Seiten") # Nehme die Seitenzahl der diff-PDF als Basis max_pages = diff_doc.pageCount() # Performance-Test: Messe Thumbnail-Erstellung start_time = time.time() # Erstelle nur Thumbnails (keine Vollbilder) for page_num in range(max_pages): # Nur diff-Seite für Thumbnail rendern page_size = diff_doc.pagePointSize(page_num) # Skalierung für Thumbnail (entspricht ca. Matrix(1.0, 1.0) in PyMuPDF) scale_factor = 200.0 / page_size.width() # 200 Pixel Breite für Thumbnail # Seite rendern page_image = diff_doc.render( page_num, QSize(int(page_size.width() * scale_factor), int(page_size.height() * scale_factor)) ) diff_pixmap = QPixmap.fromImage(page_image) # Thumbnail erstellen und zur linken Spalte hinzufügen thumbnail = QLabel() thumbnail.setObjectName(f"thumbnail_{pdf_filename}_page_{page_num + 1}") thumbnail.setPixmap(diff_pixmap.scaledToWidth(200, Qt.TransformationMode.SmoothTransformation)) thumbnail.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) thumbnail.setMouseTracking(True) self.ui.verticalLayout_2.addWidget(thumbnail) # Seitennummer für Thumbnail anzeigen thumbnail_info = QLabel(f"Seite {page_num + 1}") thumbnail_info.setAlignment(Qt.AlignmentFlag.AlignCenter) self.ui.verticalLayout_2.addWidget(thumbnail_info) # Beziehung zwischen Thumbnail und Seitennummer speichern self.thumbnail_to_page[thumbnail] = {"pdf_filename": pdf_filename, "page_num": page_num} # Click-Event für das Thumbnail einrichten thumbnail.mousePressEvent = lambda event, t=thumbnail: self.on_thumbnail_clicked(event, t) print(f"Thumbnail für Seite {page_num + 1} erstellt") thumbnail_time = time.time() - start_time print(f"Performance: {max_pages} Thumbnails in {thumbnail_time:.3f}s") # Setze die erste PDF als aktuelle PDF if self.current_pdf is None: self.current_pdf = pdf_filename except Exception as e: print(f"Fehler beim Laden der PDFs: {e}") # Erstelle das eine Vollbild-Label für die rechte Spalte (immer erstellen) if self.fullsize_label is None: self.fullsize_label = QLabel() self.fullsize_label.setObjectName("fullsize_current_page") self.fullsize_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.fullsize_label.setCursor(QCursor(Qt.CursorShape.OpenHandCursor)) self.ui.verticalLayout_3.addWidget(self.fullsize_label) # Drag-to-Scroll Events für das große Bild einrichten self.fullsize_label.mousePressEvent = lambda event: self.on_fullsize_mouse_press(event, self.fullsize_label) self.fullsize_label.mouseMoveEvent = lambda event: self.on_fullsize_mouse_move(event, self.fullsize_label) self.fullsize_label.mouseReleaseEvent = lambda event: self.on_fullsize_mouse_release( event, self.fullsize_label ) # Zeige die erste Seite initial an if self.current_pdf: self.render_and_display_page(self.current_pdf, 0) def render_and_display_page(self, pdf_filename, page_num): """ Rendert und zeigt eine spezifische Seite in der Vollansicht an. Cached die gerenderten Pixmaps für bessere Performance. Args: pdf_filename: Name der PDF-Datei page_num: Seitennummer (0-basiert) """ print(f"Rendere Seite {page_num + 1} von {pdf_filename}") if pdf_filename not in self.pdf_documents: print(f"PDF-Dokument {pdf_filename} nicht gefunden") return start_time = time.time() try: docs = self.pdf_documents[pdf_filename] # Diff-Seite laden (bestimmt die Abmessungen) diff_doc = docs["diff"] page_size = diff_doc.pagePointSize(page_num) # Hohe Auflösung für Vollbild (entspricht ca. Matrix(2.0, 2.0) in PyMuPDF) scale_factor = 2.0 render_size = QSize(int(page_size.width() * scale_factor), int(page_size.height() * scale_factor)) # Diff-Seite rendern (immer vorhanden) diff_image = diff_doc.render(page_num, render_size) diff_pixmap = QPixmap.fromImage(diff_image) # Ermittle die Abmessungen für weiße Seiten diff_width = diff_pixmap.width() diff_height = diff_pixmap.height() # Ref-Seite prüfen und rendern oder weiße Seite erstellen ref_doc = docs["ref"] if page_num < ref_doc.pageCount(): ref_image = ref_doc.render(page_num, render_size) ref_pixmap = QPixmap.fromImage(ref_image) print(f"Ref-Seite {page_num + 1} gerendert") else: # Erstelle weiße Seite mit gleichen Abmessungen wie Diff-Seite ref_pixmap = QPixmap(diff_width, diff_height) ref_pixmap.fill(Qt.GlobalColor.white) print(f"Weiße Ref-Seite {page_num + 1} erstellt") # New-Seite prüfen und rendern oder weiße Seite erstellen new_doc = docs["new"] if page_num < new_doc.pageCount(): new_image = new_doc.render(page_num, render_size) new_pixmap = QPixmap.fromImage(new_image) print(f"New-Seite {page_num + 1} gerendert") else: # Erstelle weiße Seite mit gleichen Abmessungen wie Diff-Seite new_pixmap = QPixmap(diff_width, diff_height) new_pixmap.fill(Qt.GlobalColor.white) print(f"Weiße New-Seite {page_num + 1} erstellt") # Cache die gerenderten Pixmaps für schnelle Alpha/Zoom-Operationen self.current_rendered_pixmaps = {"ref": ref_pixmap, "diff": diff_pixmap, "new": new_pixmap} # Aktualisiere aktuelle Seite self.current_page = page_num self.current_pdf = pdf_filename # Zeige das Bild mit aktuellem Alpha- und Zoom-Wert an self.update_current_display() render_time = time.time() - start_time print(f"Performance: Seite {page_num + 1} gerendert in {render_time:.3f}s") except Exception as e: print(f"Fehler beim Rendern der Seite {page_num + 1}: {e}") def update_current_display(self): """ Aktualisiert die Anzeige der aktuellen Seite basierend auf gecachten Pixmaps. Verwendet für Alpha- und Zoom-Änderungen ohne erneutes PDF-Rendering. """ if not self.current_rendered_pixmaps: print("Keine gerenderten Pixmaps verfügbar") return if self.fullsize_label is None: print("Fullsize-Label ist nicht verfügbar") return # Hole die gecachten Pixmaps ref_pixmap = self.current_rendered_pixmaps["ref"] diff_pixmap = self.current_rendered_pixmaps["diff"] new_pixmap = self.current_rendered_pixmaps["new"] # Erstelle das überlagerte Bild mit aktuellem Alpha-Wert alpha_value = self.ui.alpha.value() layered_pixmap = self.create_layered_pixmap(ref_pixmap, diff_pixmap, new_pixmap, alpha_value) # Wende aktuellen Zoom an zoom_factor = self.current_zoom / 100.0 if zoom_factor != 1.0: new_width = int(layered_pixmap.width() * zoom_factor) layered_pixmap = layered_pixmap.scaledToWidth(new_width, Qt.TransformationMode.SmoothTransformation) # Setze das überlagerte Bild self.fullsize_label.setPixmap(layered_pixmap) self.fullsize_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) def create_layered_pixmap(self, ref_pixmap, diff_pixmap, new_pixmap, alpha_value): """ Erstellt ein übergelagertes Pixmap basierend auf dem Alpha-Wert. Args: ref_pixmap: Unterste Ebene (ref) diff_pixmap: Mittlere Ebene (diff) new_pixmap: Oberste Ebene (new) alpha_value: Alpha-Wert (-100 bis 100) Returns: QPixmap: Das überlagerte Bild """ # Verwende die Größe des größten Bildes max_width = max(ref_pixmap.width(), diff_pixmap.width(), new_pixmap.width()) max_height = max(ref_pixmap.height(), diff_pixmap.height(), new_pixmap.height()) # Erstelle ein leeres Pixmap für das Ergebnis result = QPixmap(max_width, max_height) result.fill(Qt.GlobalColor.white) painter = QPainter(result) painter.setRenderHint(QPainter.RenderHint.Antialiasing) if alpha_value <= 0: # Alpha von -100 bis 0: Übergang von ref zu diff ref_opacity = 1.0 # - (alpha_value + 100) / 100.0 diff_opacity = (alpha_value + 100) / 100.0 new_opacity = 0.0 else: # Alpha von 0 bis 100: Übergang von diff zu new ref_opacity = 0.0 diff_opacity = 1.0 # - alpha_value / 100.0 new_opacity = alpha_value / 100.0 # Zeichne die Ebenen mit entsprechender Transparenz if ref_opacity > 0: painter.setOpacity(ref_opacity) painter.drawPixmap(0, 0, ref_pixmap) if diff_opacity > 0: painter.setOpacity(diff_opacity) painter.drawPixmap(0, 0, diff_pixmap) if new_opacity > 0: painter.setOpacity(new_opacity) painter.drawPixmap(0, 0, new_pixmap) painter.end() return result def _clear_layout(self, layout): """Entfernt alle Widgets aus einem Layout.""" if layout is not None: while layout.count(): item = layout.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() def _connect_signals(self): """Verbindet Signale mit den entsprechenden Slots.""" # Button-Klicks verbinden self.ui.pushButton.clicked.connect(self.on_button_clicked) # Zoom-Slider verbinden self.ui.zoom.valueChanged.connect(self.apply_zoom) self.ui.zoom.mouseDoubleClickEvent = lambda event: self.ui.zoom.setValue(100) # Alpha-Slider verbinden self.ui.alpha.valueChanged.connect(self.on_alpha_changed) self.ui.alpha.mouseDoubleClickEvent = lambda event: self.ui.alpha.setValue(0) # Menü-Aktionen verbinden self.ui.actionNeu.triggered.connect(self.open_new_project_dialog) self.ui.actionEinstellungen.triggered.connect(self.open_settings_dialog) # Button "lade aus FN2" verbinden self.ui.pB_lade_aus_fn2.clicked.connect(self.on_load_from_fn2_clicked) 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) print("Kontextmenü für TreeWidget eingerichtet") def _setup_tree_widget_styling(self): """Richtet das Styling für das TreeWidget ein, um den vertikalen Abstand zu vergrößern.""" try: # Stylesheet für größeren vertikalen Abstand zwischen Items tree_stylesheet = """ QTreeWidget::item { padding: 4px 4px; } QTreeWidget::item:selected { background-color: palette(highlight); color: palette(highlighted-text); } QTreeWidget::item:hover { background-color: palette(alternate-base); } QTreeWidget::branch { /*margin: 2px 0px;*/ } """ # Wende das Stylesheet auf das TreeWidget an self.ui.treeWidget.setStyleSheet(tree_stylesheet) print("TreeWidget Styling für größeren vertikalen Abstand angewendet") except Exception as e: print(f"Fehler beim Anwenden des TreeWidget-Stylings: {e}") 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 = "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 _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 "XmlFile" 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 == "XslFile": return "XmlFile" 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: print(f"Fehler beim Bestimmen des Node-Typs: {e}") return "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 "Unknown" # Bestimme den Typ direkt vom Node-Objekt if isinstance(node, TreeNode): return "TreeNode" elif isinstance(node, XslFile): return "XslFile" elif isinstance(node, XmlFile): return "XmlFile" return "Unknown" except Exception as e: print(f"Fehler beim Bestimmen des Node-Typs aus Daten: {e}") return "Unknown" 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 == "TreeNode": # 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() 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 == "XslFile": # 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 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)) 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)) 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_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 == "XmlFile": # 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: print(f"Fehler beim Erstellen des Kontextmenüs: {e}") return None def on_alpha_changed(self, alpha_value): """ Wird ausgeführt, wenn der Alpha-Slider geändert wird. Optimiert: Verwendet gecachte Pixmaps statt erneutes PDF-Rendering. Args: alpha_value: Der neue Alpha-Wert (-100 bis 100) """ print(f"Alpha geändert auf {alpha_value}") start_time = time.time() # Verwende gecachte Pixmaps für schnelle Alpha-Änderungen self.update_current_display() alpha_time = time.time() - start_time print(f"Alpha-Update in {alpha_time:.6f}s") def open_settings_dialog(self): """Öffnet den Einstellungen-Dialog.""" try: # Erstelle und zeige den Dialog dialog = AppSettingsDlg(self, app_settings) if dialog.exec() == AppSettingsDlg.DialogCode.Accepted: # Einstellungen wurden gespeichert, hier könnten weitere Aktionen folgen print("Einstellungen wurden gespeichert") except Exception as e: print(f"Fehler beim Öffnen des Einstellungen-Dialogs: {e}") def open_new_project_dialog(self): """Öffnet Pdf-Projekt-Dialog.""" try: # Erstelle und zeige den PdfProject-Dialog dialog = PdfProjectDlg(self) if dialog.exec() == PdfProjectDlg.DialogCode.Accepted: # Hole die Projektdaten aus dem Dialog project_data = dialog.get_project_data() # Erstelle neue ID für das Projekt new_id = max([p.id for p in app_settings.pdf_projects], default=0) + 1 # Erstelle PdfProject-Objekt new_project = Project( id=new_id, name=project_data["name"], project_dir=Path(project_data["project_dir"]), java_vm_id=project_data["java_vm_id"] if project_data["java_vm_id"] != -1 else 1, diff_pdf_id=project_data["diff_pdf_id"] if project_data["diff_pdf_id"] != -1 else 1, saxon_jar_id=project_data["saxon_jar_id"] if project_data["saxon_jar_id"] != -1 else 1, apache_fop_id=project_data["apache_fop_id"] if project_data["apache_fop_id"] != -1 else 1, xsl_dir_id=project_data["xsl_dir_id"] if project_data["xsl_dir_id"] != -1 else 1, postgre_sql_db_id=project_data["postgre_sql_db_id"] if project_data["postgre_sql_db_id"] != -1 else 1, ) # Erstelle Projekt-Ordnerstruktur self._create_project_structure(new_project) # Füge das neue Projekt zu app_settings hinzu app_settings.pdf_projects.append(new_project) # Speichere app_settings app_settings.save() print(f"Neues PDF-Projekt '{project_data['name']}' wurde erstellt und gespeichert") print(f"Projekt-ID: {new_id}") print(f"Projekt-Ordner: {project_data['project_dir']}") # Aktualisiere das Projekte-Menü self._setup_projects_menu() except Exception as e: print(f"Fehler beim Erstellen des neuen Projekts: {e}") def _create_project_structure(self, project: Project): """ Erstellt die Ordnerstruktur und project.yaml-Datei für ein neues Projekt. Args: project: Das PdfProject-Objekt """ try: project_dir = Path(project.project_dir) # Erstelle Unterordner subdirs = ["xml", "new", "diff", "ref", "tmp"] for subdir in subdirs: subdir_path = project_dir / subdir subdir_path.mkdir(parents=True, exist_ok=True) print(f"Ordner erstellt: {subdir_path}") project_yaml_path = project_dir / "project.yaml" # Erstelle Standard-Projekt-Einstellungen und speichere sie if not project_yaml_path.exists(): # Erstelle Standard-PdfProjectSettings default_settings = ProjectData() # Speichere die Standard-Einstellungen in die project.yaml default_settings.writeSettings(project_dir=project_dir) print(f"project.yaml mit Standard-Einstellungen erstellt: {project_yaml_path}") else: print(f"project.yaml existiert bereits: {project_yaml_path}") except Exception as e: print(f"Fehler beim Erstellen der Projekt-Struktur: {e}") raise def on_button_clicked(self): """Wird ausgeführt, wenn der Button geklickt wird.""" print("Button wurde geklickt!") def on_thumbnail_clicked(self, event, thumbnail): """ Wird ausgeführt, wenn ein Thumbnail angeklickt wird. Args: event: Das Maus-Event thumbnail: Das geklickte Thumbnail-Label """ page_info = self.thumbnail_to_page.get(thumbnail) if page_info: pdf_filename = page_info["pdf_filename"] page_num = page_info["page_num"] print(f"Thumbnail für Seite {page_num + 1} von {pdf_filename} wurde angeklickt") # Rendere und zeige die gewählte Seite an self.render_and_display_page(pdf_filename, page_num) def apply_zoom(self, zoom_value): """ Wendet den Zoom-Faktor auf das aktuelle Bild an. Optimiert: Verwendet gecachte Pixmaps statt erneutes PDF-Rendering. Args: zoom_value: Der neue Zoom-Wert (in Prozent) """ self.current_zoom = zoom_value print(f"Zoom geändert auf {zoom_value}%") # Verwende gecachte Pixmaps für schnelle Zoom-Änderungen self.update_current_display() def on_fullsize_mouse_press(self, event, fullsize_label): """Wird ausgeführt, wenn die Maustaste auf einem großen Bild gedrückt wird.""" if event.button() == Qt.MouseButton.LeftButton: self.is_dragging = True self.last_drag_position = event.globalPosition().toPoint() fullsize_label.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor)) def on_fullsize_mouse_move(self, event, fullsize_label): """Wird ausgeführt, wenn die Maus über einem großen Bild bewegt wird.""" if self.is_dragging and self.last_drag_position is not None: current_pos = event.globalPosition().toPoint() delta = current_pos - self.last_drag_position if abs(delta.x()) >= self.drag_threshold or abs(delta.y()) >= self.drag_threshold: v_scrollbar = self.ui.scrollArea_2.verticalScrollBar() h_scrollbar = self.ui.scrollArea_2.horizontalScrollBar() scroll_delta_y = int(-delta.y() * self.scroll_sensitivity) scroll_delta_x = int(-delta.x() * self.scroll_sensitivity) new_v_value = v_scrollbar.value() + scroll_delta_y new_h_value = h_scrollbar.value() + scroll_delta_x v_scrollbar.setValue(new_v_value) h_scrollbar.setValue(new_h_value) self.last_drag_position = current_pos def on_fullsize_mouse_release(self, event, fullsize_label): """Wird ausgeführt, wenn die Maustaste auf einem großen Bild losgelassen wird.""" if event.button() == Qt.MouseButton.LeftButton: self.is_dragging = False self.last_drag_position = None fullsize_label.setCursor(QCursor(Qt.CursorShape.OpenHandCursor)) def _load_nodes_to_tree(self): """ Lädt die Nodes aus den Projekt-Einstellungen in das TreeWidget. Sortiert die Items alphabetisch nach ihrer ID. """ print("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: print("Keine Projekt-Einstellungen verfügbar") return if not self.pdf_project.nodes: print("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) print(f"{len(self.pdf_project.nodes)} Root-Nodes in TreeWidget geladen (alphabetisch sortiert)") except Exception as e: print(f"Fehler beim Laden der Nodes in TreeWidget: {e}") 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 zusätzliche Informationen in Spalte 1 if isinstance(node, TreeNode): # TreeNode: Zeige Anzahl der Knoten child_count = len(node.children) if node.children else 0 item.setText(1, f"{child_count} Knoten") # 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) elif isinstance(node, XslFile): # XslFile: Zeige XSL-Datei-Pfad item.setText(1, str(node.xsl_file)) # Speichere zusätzlich die Node-ID in UserRole+1 für Kompatibilität item.setData(0, Qt.ItemDataRole.UserRole + 1, node.id) # 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}") xml_item.setText(1, str(xml.xml)) # 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}") 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: print(f"Fehler beim Erstellen des Tree-Items: {e}") # Fallback: Erstelle einfaches Item fallback_item = QTreeWidgetItem() fallback_item.setText(0, "Fehler beim Laden") fallback_item.setText(1, str(e)) return fallback_item def _create_centered_progress_bar(self) -> tuple[QWidget, QProgressBar]: """ Erstellt eine zentrierte 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.AlignCenter) # 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) -> QWidget: """ Erstellt ein zentriertes, klickbares Icon für Diff-PDF. Args: xml_file_path: Pfad zur XML-Datei (für Event-Handler) Returns: QWidget: Container mit klickbarem Icon """ # Container-Widget container = QWidget() layout = QHBoxLayout(container) layout.setContentsMargins(0, 0, 0, 0) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) # Icon-Label icon_label = QLabel() icon_label.setPixmap(QIcon.fromTheme("document-preview").pixmap(16, 16)) icon_label.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) icon_label.setToolTip("Diff-PDF öffnen (Doppelklick)") # Klick-Event für Icon (Doppelklick öffnet PDF) icon_label.mouseDoubleClickEvent = lambda event: self._open_diff_pdf(xml_file_path) layout.addWidget(icon_label) return container def _open_diff_pdf(self, xml_file_path: Path): """ Öffnet die Diff-PDF für eine XML-Datei mit dem Standard-PDF-Viewer. Args: xml_file_path: Pfad zur XML-Datei (relativ) """ import subprocess import sys try: # Ermittle Diff-PDF-Pfad basierend auf XML-Datei # WICHTIG: Berücksichtige XSL-ID im Dateinamen! # Vereinfachung: Suche nach allen PDFs die mit xml_stem beginnen xml_stem = xml_file_path.stem diff_dir = self.project.project_dir / "diff" # Finde passende Diff-PDF (könnte mehrere geben bei verschiedenen XSL-IDs) matching_pdfs = list(diff_dir.glob(f"{xml_stem}*.pdf")) if not matching_pdfs: QMessageBox.information(self, "Keine Diff-PDF", f"Keine Diff-PDF für {xml_file_path.name} gefunden") return # Bei mehreren: Nehme neueste diff_pdf = max(matching_pdfs, key=lambda p: p.stat().st_mtime) logger.info(f"Öffne Diff-PDF: {diff_pdf}") # Öffne PDF mit Plattform-spezifischem Befehl if sys.platform == "win32": subprocess.Popen(["start", str(diff_pdf)], shell=True) elif sys.platform == "darwin": subprocess.Popen(["open", str(diff_pdf)]) else: subprocess.Popen(["xdg-open", str(diff_pdf)]) logger.info(f"Diff-PDF geöffnet: {diff_pdf}") except Exception as e: logger.error(f"Fehler beim Öffnen der Diff-PDF: {e}") QMessageBox.critical(self, "Fehler", f"Konnte Diff-PDF nicht öffnen: {str(e)}") def _update_diff_icons_for_existing_pdfs(self): """ Durchläuft alle XML-Items und setzt Icons für bereits existierende Diff-PDFs. Wird nach dem Laden eines Projekts aufgerufen. """ if not hasattr(self, "project") or not self.project: logger.debug("Kein Projekt geladen, überspringe Diff-Icon-Update") return diff_dir = self.project.project_dir / "diff" if not diff_dir.exists(): logger.debug(f"Diff-Ordner existiert nicht: {diff_dir}") return logger.info("Aktualisiere Diff-Icons für existierende PDFs...") logger.info(f"XML-Item-Map hat {len(self.xml_item_map)} Einträge") # Durchlaufe alle XML-Items in der Map icon_count = 0 for map_key, tree_item in self.xml_item_map.items(): # Map-Key hat Format "xml_path|xsl_id" parts = map_key.split("|") if len(parts) != 2: continue xml_path_str, xsl_id_str = parts xml_path = Path(xml_path_str) xml_stem = xml_path.stem # Diff-PDF-Dateiname: "{xml_stem}_xsl_{xsl_id_str}.pdf" expected_pdf = diff_dir / f"{xml_stem}_xsl_{xsl_id_str}.pdf" if expected_pdf.exists(): # Icon setzen icon_widget = self._create_centered_diff_icon(xml_path) self.ui.treeWidget.setItemWidget(tree_item, 2, icon_widget) icon_count += 1 logger.debug(f"Diff-Icon für existierende PDF gesetzt: {map_key}") logger.info(f"{icon_count} Diff-Icons für existierende PDFs gesetzt") # Kontextmenü-Aktionen für TreeNode def _add_tree_node_child(self, parent_item): """Fügt einen Unterknoten zu einem TreeNode hinzu.""" print(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.""" print(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 """ print(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"] print(f"TreeNode '{node.bez}' wurde aktualisiert") print(f"XSLT-Parameter: {node.xslt_params}") # Speichere die Änderungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() # QMessageBox.information(self, "Erfolg", "TreeNode wurde erfolgreich aktualisiert.") except Exception as e: error_msg = f"Fehler beim Bearbeiten des TreeNode: {str(e)}" print(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _delete_tree_node(self, item): """Löscht einen TreeNode.""" print(f"TreeNode löschen: {item.text(0)}") # TODO: Bestätigungsdialog und Löschung implementieren # 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 """ print(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) print(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) print(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() # QMessageBox.information( # self, # "Erfolg", # f"XML-Datei '{xml_file_path.name}' wurde erfolgreich hinzugefügt und in den xml-Ordner kopiert." # ) except Exception as e: error_msg = f"Fehler beim Hinzufügen der XML-Datei: {str(e)}" print(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _edit_xsl_file(self, item): """ Bearbeitet eine XSL-Datei. Args: item: Das TreeWidgetItem des XslFile """ print(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"] print(f"XslFile '{node.bez}' wurde aktualisiert") print(f"XSLT-Parameter: {node.xslt_params}") # Speichere die Änderungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() # QMessageBox.information(self, "Erfolg", "XSL-Datei wurde erfolgreich aktualisiert.") except Exception as e: error_msg = f"Fehler beim Bearbeiten der XSL-Datei: {str(e)}" print(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _delete_xsl_file(self, item): """Löscht eine XSL-Datei.""" print(f"XslFile löschen: {item.text(0)}") # TODO: Bestätigungsdialog und Löschung implementieren # Kontextmenü-Aktionen für XmlFile def _edit_xml_file(self, item): """Bearbeitet eine XML-Datei.""" print(f"XmlFile bearbeiten: {item.text(0)}") # TODO: Dialog zum Bearbeiten der XML-Datei öffnen def _delete_xml_file(self, item): """ Löscht eine XML-Datei aus einem XSL-Knoten. Args: item: Das TreeWidgetItem der XML-Datei """ print(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: print("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 print(f"XML-Datei '{xml_filename}' aus XSL-Datei '{xsl_file_obj.bez}' entfernt") # 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() print(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: print( 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() print(f"XML-Datei '{xml_filename}' erfolgreich entfernt") except Exception as e: error_msg = f"Fehler beim Löschen der XML-Datei: {str(e)}" print(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: print(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 # Kontextmenü-Aktionen für Root-Elemente (Unbekannter Typ) def _add_root_tree_node(self): """Fügt einen neuen TreeNode als Root-Element hinzu.""" print("Neuen TreeNode als Root-Element hinzufügen") # TODO: Dialog zum Eingeben der TreeNode-Daten öffnen def on_load_from_fn2_clicked(self): """ Wird ausgeführt, wenn der Button "lade aus FN2" geklickt wird. Führt SQL-Abfrage aus und aktualisiert die Projekt-Nodes. """ print("Button 'lade aus FN2' wurde geklickt!") 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 # Hole das aktuelle Projekt aus app_settings if not self.project: QMessageBox.warning(self, "Warnung", "Aktuelles Projekt nicht in den Einstellungen gefunden.") return # Hole die PostgreSQL-Datenbank-Konfiguration db_config = self._get_database_config(self.project.postgre_sql_db_id) if not db_config: QMessageBox.warning(self, "Warnung", "PostgreSQL-Datenbank-Konfiguration nicht gefunden.") return # Führe SQL-Abfrage aus df = self._execute_sql_query(db_config) if df is None: return # Fehler bereits angezeigt # Verarbeite die Daten wie in readCsv.py new_nodes = self._process_sql_data(df) # Merge mit vorhandenen Nodes self._merge_nodes_with_existing(new_nodes) # Speichere die aktualisierten Projekt-Einstellungen self._save_project_settings() # Lade das Projekt neu self._load_nodes_to_tree() # QMessageBox.information(self, "Erfolg", "Daten erfolgreich aus FN2 geladen und Projekt aktualisiert!") except Exception as e: print(f"Fehler beim Laden aus FN2: {e}") QMessageBox.critical(self, "Fehler", f"Fehler beim Laden aus FN2:\n{str(e)}") def _get_database_config(self, db_id): """ Holt die Datenbank-Konfiguration anhand der ID. Args: db_id: ID der PostgreSQL-Datenbank Returns: PostgreSqlDb|None: Die Datenbank-Konfiguration oder None """ for db in app_settings.postgresql_dbs: if db.id == db_id: return db return None def _execute_sql_query(self, db_config): """ Führt die SQL-Abfrage aus der data.sql Datei aus. Args: db_config: PostgreSQL-Datenbank-Konfiguration Returns: pl.DataFrame|None: Die Abfrageergebnisse oder None bei Fehler """ try: # Lade SQL-Abfrage aus Datei sql_file_path = Path("src/res/data.sql") if not sql_file_path.exists(): QMessageBox.critical(self, "Fehler", f"SQL-Datei nicht gefunden: {sql_file_path}") return None with open(sql_file_path, "r", encoding="utf-8") as f: sql_query = f.read() print(f"SQL-Abfrage geladen: {len(sql_query)} Zeichen") # Verbindung zur PostgreSQL-Datenbank herstellen connection_string = ( "postgresql://" f"{db_config.username}:" f"{db_config.password}@" f"{db_config.host}:" f"{db_config.port}/" f"{db_config.database}?" f"sslmode={db_config.ssl_mode.value}" ) print(f"Verbinde zu PostgreSQL: {db_config.host}:{db_config.port}/{db_config.database}") df = pl.read_database_uri(sql_query, connection_string, engine="connectorx").sort( ["reporttyp_bez", "report_bez", "repfile_bez"] ) return df except Exception as e: error_msg = f"Fehler beim Ausführen der SQL-Abfrage: {str(e)}" print(error_msg) QMessageBox.critical(self, "Fehler", error_msg) return None def _process_sql_data(self, df): """ Verarbeitet die SQL-Daten wie in readCsv.py und erstellt Node-Struktur. Args: df: Polars DataFrame mit den SQL-Ergebnissen Returns: list[TreeNode]: Liste der erstellten Root-Nodes """ try: start_time = time.time() # Gruppiere die Daten wie in readCsv.py ebene_1 = df.group_by(["reporttyp", "reporttyp_bez"]).len() ebene_2 = df.group_by(["reporttyp", "report", "report_bez"]).len() ebene_3 = df.group_by(["reporttyp", "report", "repfile", "repfile_bez", "xsl_datei"]).len() group_time = time.time() - start_time print(f"Performance: Gruppierung in {group_time:.3f}s") new_nodes = [] start_time = time.time() # Erstelle Node-Struktur wie in readCsv.py for r1 in ebene_1.rows(named=True): tn_1 = TreeNode(id=(r1["reporttyp"],), bez=r1["reporttyp_bez"], children=[]) r1_children = ebene_2.filter(pl.col("reporttyp") == r1["reporttyp"]) for r2 in r1_children.rows(named=True): tn_2 = TreeNode(id=(r2["reporttyp"], r2["report"]), bez=r2["report_bez"], children=[]) r2_children = ebene_3.filter( (pl.col("reporttyp") == r1["reporttyp"]) & (pl.col("report") == r2["report"]) ) for r3 in r2_children.rows(named=True): x = XslFile( id=(r3["reporttyp"], r3["report"], r3["repfile"]), bez=r3["repfile_bez"], xsl_file=Path(r3["xsl_datei"]), xmls=[], ) tn_2.children.append(x) tn_1.children.append(tn_2) new_nodes.append(tn_1) nodes_time = time.time() - start_time print(f"Performance: Node-Erstellung in {nodes_time:.3f}s") print(f"Erstellt: {len(new_nodes)} Root-Nodes") return new_nodes except Exception as e: print(f"Fehler beim Verarbeiten der SQL-Daten: {e}") raise def _merge_nodes_with_existing(self, new_nodes): """ Merged neue Nodes mit vorhandenen Nodes basierend auf IDs. Überschreibt nur einzelne Eigenschaften, nicht ganze Nodes. Args: new_nodes: Liste der neuen Nodes """ try: print("Merge neue Nodes mit vorhandenen...") # Erstelle ein Dictionary der neuen Nodes für schnellen Zugriff new_nodes_dict = {} self._build_nodes_dict(new_nodes, new_nodes_dict) print(f"Neue Nodes Dictionary erstellt: {len(new_nodes_dict)} Einträge") # Merge mit vorhandenen Nodes if self.pdf_project and self.pdf_project.nodes: self._merge_nodes_recursive(self.pdf_project.nodes, new_nodes_dict) # Füge komplett neue Root-Nodes hinzu if self.pdf_project and self.pdf_project.nodes: existing_root_ids = {node.id for node in self.pdf_project.nodes} for new_node in new_nodes: if new_node.id not in existing_root_ids: self.pdf_project.nodes.append(new_node) print(f"Neue Root-Node hinzugefügt: {new_node.bez}") elif self.pdf_project: # Wenn keine Nodes vorhanden sind, füge alle neuen Nodes hinzu self.pdf_project.nodes = new_nodes print(f"Alle {len(new_nodes)} Root-Nodes hinzugefügt (keine vorhandenen Nodes)") print("Merge abgeschlossen") except Exception as e: print(f"Fehler beim Mergen der Nodes: {e}") raise def _build_nodes_dict(self, nodes, nodes_dict): """ Erstellt rekursiv ein Dictionary aller Nodes für schnellen ID-basierten Zugriff. Args: nodes: Liste der Nodes nodes_dict: Dictionary zum Füllen """ for node in nodes: nodes_dict[node.id] = node if isinstance(node, TreeNode) and node.children: self._build_nodes_dict(node.children, nodes_dict) def _merge_nodes_recursive(self, existing_nodes, new_nodes_dict): """ Merged rekursiv vorhandene Nodes mit neuen Nodes. Args: existing_nodes: Liste der vorhandenen Nodes new_nodes_dict: Dictionary der neuen Nodes """ for existing_node in existing_nodes: if existing_node.id in new_nodes_dict: new_node = new_nodes_dict[existing_node.id] # Aktualisiere nur die Bezeichnung, falls sie sich geändert hat if existing_node.bez != new_node.bez: print( f"Aktualisiere Bezeichnung für Node {existing_node.id}: '{existing_node.bez}' -> '{new_node.bez}'" ) existing_node.bez = new_node.bez # Für XslFile: Aktualisiere xsl_file Pfad if isinstance(existing_node, XslFile) and isinstance(new_node, XslFile): if existing_node.xsl_file != new_node.xsl_file: print( f"Aktualisiere XSL-Datei für Node {existing_node.id}: '{existing_node.xsl_file}' -> '{new_node.xsl_file}'" ) existing_node.xsl_file = new_node.xsl_file # Rekursiv für Knoten (nur bei TreeNode) if isinstance(existing_node, TreeNode) and existing_node.children: self._merge_nodes_recursive(existing_node.children, new_nodes_dict) # Füge neue Knoten hinzu, die noch nicht existieren if existing_node.id in new_nodes_dict: new_node = new_nodes_dict[existing_node.id] if isinstance(new_node, TreeNode) and new_node.children: existing_child_ids = {child.id for child in existing_node.children} for new_child in new_node.children: if new_child.id not in existing_child_ids: existing_node.children.append(new_child) print(f"Neues Kind hinzugefügt zu Node {existing_node.id}: {new_child.bez}") def _collect_parent_params(self, item): """ Sammelt die XSLT-Parameter aller Eltern-Nodes. Args: item: Das TreeWidgetItem Returns: dict: Dictionary mit allen Eltern-Parametern """ parent_params = {} try: # Gehe die Hierarchie nach oben durch current_item = item.parent() while current_item: # Hole das Node-Objekt parent_node = current_item.data(0, Qt.ItemDataRole.UserRole) if parent_node and hasattr(parent_node, "xslt_params") and parent_node.xslt_params: # Füge die Parameter des Eltern-Nodes hinzu # Eltern-Parameter haben niedrigere Priorität (werden überschrieben) for key, value in parent_node.xslt_params.items(): if key not in parent_params: # Nur hinzufügen wenn noch nicht vorhanden parent_params[key] = value # Gehe zum nächsten Eltern-Element current_item = current_item.parent() print(f"Gesammelte Eltern-Parameter: {parent_params}") return parent_params except Exception as e: print(f"Fehler beim Sammeln der Eltern-Parameter: {e}") return {} def _save_project_settings(self): """ Speichert die aktualisierten Projekt-Einstellungen. Args: current_project: Das aktuelle Projekt """ try: # Prüfe ob pdf_project und project existieren if not self.pdf_project: print("Keine Projekt-Einstellungen zum Speichern verfügbar") return if not self.project or not self.project.project_dir: print("Kein Projekt-Verzeichnis zum Speichern verfügbar") return start_time = time.time() # Speichere in project.yaml im Projekt-Verzeichnis self.pdf_project.writeSettings(project_dir=self.project.project_dir) dump_time = time.time() - start_time print(f"Performance: Projekt-Einstellungen gespeichert in {dump_time:.3f}s") except Exception as e: print(f"Fehler beim Speichern der Projekt-Einstellungen: {e}") raise def _setup_drag_drop(self): """Aktiviert Drag&Drop für das TreeWidget.""" try: # Aktiviere Drag&Drop für das TreeWidget self.ui.treeWidget.setAcceptDrops(True) self.ui.treeWidget.setDragDropMode(self.ui.treeWidget.DragDropMode.DropOnly) # Überschreibe die Drag&Drop-Events self.ui.treeWidget.dragEnterEvent = self.tree_drag_enter_event self.ui.treeWidget.dragMoveEvent = self.tree_drag_move_event self.ui.treeWidget.dropEvent = self.tree_drop_event print("Drag&Drop für TreeWidget aktiviert") except Exception as e: print(f"Fehler beim Aktivieren von Drag&Drop: {e}") def tree_drag_enter_event(self, event: QDragEnterEvent): """ Wird ausgeführt, wenn ein Drag-Vorgang über das TreeWidget beginnt. Args: event: Das Drag-Enter-Event """ try: # Prüfe ob URLs (Dateien) gedraggt werden if event.mimeData().hasUrls(): urls = event.mimeData().urls() # Prüfe ob mindestens eine XML-Datei dabei ist xml_files = [url.toLocalFile() for url in urls if url.toLocalFile().lower().endswith(".xml")] if xml_files: event.acceptProposedAction() print(f"Drag-Enter akzeptiert: {len(xml_files)} XML-Dateien") else: event.ignore() print("Drag-Enter ignoriert: Keine XML-Dateien") else: event.ignore() print("Drag-Enter ignoriert: Keine URLs") except Exception as e: print(f"Fehler in tree_drag_enter_event: {e}") event.ignore() def tree_drag_move_event(self, event): """ Wird ausgeführt, wenn ein Drag-Vorgang über das TreeWidget bewegt wird. Args: event: Das Drag-Move-Event """ try: # Prüfe ob URLs (Dateien) gedraggt werden if event.mimeData().hasUrls(): urls = event.mimeData().urls() # Prüfe ob mindestens eine XML-Datei dabei ist xml_files = [url.toLocalFile() for url in urls if url.toLocalFile().lower().endswith(".xml")] if xml_files: event.acceptProposedAction() else: event.ignore() else: event.ignore() except Exception as e: print(f"Fehler in tree_drag_move_event: {e}") event.ignore() def tree_drop_event(self, event: QDropEvent): """ Wird ausgeführt, wenn Dateien auf das TreeWidget gedroppt werden. Args: event: Das Drop-Event """ 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.") event.ignore() return if not hasattr(self, "pdf_project") or not self.pdf_project: QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen geladen.") event.ignore() return # Hole die URLs aus dem Drop-Event if not event.mimeData().hasUrls(): event.ignore() return urls = event.mimeData().urls() xml_files = [] # Sammle alle XML-Dateien for url in urls: file_path = url.toLocalFile() if file_path.lower().endswith(".xml"): xml_files.append(Path(file_path)) if not xml_files: QMessageBox.information(self, "Information", "Keine XML-Dateien zum Hinzufügen gefunden.") event.ignore() return print(f"Drop-Event: {len(xml_files)} XML-Dateien erkannt") # Verarbeite jede XML-Datei einzeln for xml_file_path in xml_files: self._handle_xml_file_drop(xml_file_path) event.acceptProposedAction() except Exception as e: error_msg = f"Fehler beim Verarbeiten des Drop-Events: {str(e)}" print(error_msg) QMessageBox.critical(self, "Fehler", error_msg) event.ignore() def _handle_xml_file_drop(self, xml_file_path: Path): """ Verarbeitet eine einzelne XML-Datei, die per Drag&Drop hinzugefügt wurde. Args: xml_file_path: Pfad zur XML-Datei """ try: print(f"Verarbeite XML-Datei: {xml_file_path}") # Prüfe ob die Datei existiert if not xml_file_path.exists(): QMessageBox.critical(self, "Fehler", f"Die XML-Datei existiert nicht:\n{xml_file_path}") return # Prüfe ob Projekt-Nodes verfügbar sind if not self.pdf_project or not self.pdf_project.nodes: QMessageBox.warning(self, "Warnung", "Keine Projekt-Nodes verfügbar für die Zuordnung.") return # Öffne den Dialog zur Zuordnung zu XSL-Knoten dialog = XmlToXslAssignDialog( parent=self, xml_file_path=xml_file_path, project_nodes=self.pdf_project.nodes ) if dialog.exec() == XmlToXslAssignDialog.DialogCode.Accepted: # Hole die ausgewählten XSL-Knoten selected_xsl_nodes = dialog.get_selected_xsl_nodes() if selected_xsl_nodes: # Verarbeite die Zuordnung self._assign_xml_to_xsl_nodes(xml_file_path, selected_xsl_nodes) else: print("Keine XSL-Knoten ausgewählt") else: print("Dialog abgebrochen") except Exception as e: error_msg = f"Fehler beim Verarbeiten der XML-Datei '{xml_file_path}': {str(e)}" print(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _assign_xml_to_xsl_nodes(self, xml_file_path: Path, selected_xsl_nodes: list): """ Ordnet eine XML-Datei den ausgewählten XSL-Knoten zu. Implementiert Hash-basierte Duplikatserkennung und intelligente Dateinamen-Verwaltung. Args: xml_file_path: Pfad zur XML-Datei selected_xsl_nodes: Liste der ausgewählten XSL-Knoten """ try: logger.info(f"Ordne XML-Datei '{xml_file_path.name}' zu {len(selected_xsl_nodes)} XSL-Knoten zu") # 1. Hash für die neue XML-Datei berechnen file_hash = self._calculate_hash_for_file(xml_file_path) if not file_hash: logger.warning(f"Hash-Berechnung für {xml_file_path} fehlgeschlagen") # 2. Prüfe ob eine XML-Datei mit gleichem Hash bereits im Projekt existiert existing_xml = self._find_xml_file_by_hash(file_hash) if file_hash else None if existing_xml: # 3. Hash-Match gefunden: Ordne vorhandene XML-Datei zu logger.info(f"Hash-Duplikat gefunden: {existing_xml.xml} hat gleichen Hash wie {xml_file_path.name}") self._assign_existing_xml_to_nodes(existing_xml, selected_xsl_nodes) else: # 4. Kein Hash-Match: Verarbeite als neue XML-Datei logger.info(f"Keine Hash-Duplikate gefunden für {xml_file_path.name}, verarbeite als neue Datei") self._process_new_xml_file(xml_file_path, selected_xsl_nodes, file_hash) except Exception as e: error_msg = f"Fehler beim Zuordnen der XML-Datei: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _start_xml_hash_calculation(self): """ Startet die asynchrone Hash-Berechnung für alle XML-Dateien im Projekt. """ try: if not hasattr(self, "pdf_project") or not self.pdf_project: logger.debug("Keine Projekt-Einstellungen verfügbar für Hash-Berechnung") return # Sammle alle XML-Dateien aus dem Projekt xml_files = self._collect_all_xml_files() if not xml_files: logger.debug("Keine XML-Dateien für Hash-Berechnung gefunden") return logger.info(f"Starte Hash-Berechnung für {len(xml_files)} XML-Dateien") # Prüfe ob Projekt verfügbar ist if not self.project or not self.project.project_dir: logger.warning("Kein Projekt-Verzeichnis für Hash-Berechnung verfügbar") return # Stoppe vorherigen Thread falls noch aktiv if self.hash_calculator_thread and self.hash_calculator_thread.isRunning(): self.hash_calculator_thread.quit() self.hash_calculator_thread.wait() # Erstelle und starte neuen Hash-Berechnungs-Thread self.hash_calculator_thread = XmlHashCalculatorThread( project_dir=Path(self.project.project_dir), xml_files=xml_files ) # Verbinde Signale self.hash_calculator_thread.hash_calculated.connect(self._on_hash_calculated) self.hash_calculator_thread.calculation_finished.connect(self._on_hash_calculation_finished) self.hash_calculator_thread.error_occurred.connect(self._on_hash_calculation_error) # Starte Thread self.hash_calculator_thread.start() except Exception as e: logger.error(f"Fehler beim Starten der Hash-Berechnung: {e}") def _collect_all_xml_files(self) -> List[XmlFile]: """ Sammelt alle XmlFile-Objekte aus der Projektstruktur. Returns: List[XmlFile]: Liste aller gefundenen XML-Dateien """ xml_files = [] try: if self.pdf_project and self.pdf_project.nodes: self._collect_xml_files_recursive(self.pdf_project.nodes, xml_files) logger.debug(f"Gesammelt: {len(xml_files)} XML-Dateien") return xml_files except Exception as e: logger.error(f"Fehler beim Sammeln der XML-Dateien: {e}") return [] def _collect_xml_files_recursive(self, nodes, xml_files: List[XmlFile]): """ Sammelt rekursiv alle XML-Dateien aus den Nodes. Args: nodes: Liste der zu durchsuchenden Nodes xml_files: Liste zum Sammeln der XML-Dateien """ for node in nodes: if isinstance(node, XslFile) and node.xmls: # Füge alle XML-Dateien dieser XSL-Datei hinzu for xml_file in node.xmls: if xml_file not in xml_files: # Vermeide Duplikate xml_files.append(xml_file) elif isinstance(node, TreeNode) and node.children: # Rekursiv in Kinder-Nodes suchen self._collect_xml_files_recursive(node.children, xml_files) def _on_hash_calculated(self, xml_file: XmlFile, hash_value: str): """ Wird aufgerufen, wenn ein Hash-Wert berechnet wurde. Args: xml_file: Das XmlFile-Objekt hash_value: Der berechnete Hash-Wert mit Präfix """ try: # Setze den Hash-Wert xml_file.hashsum = hash_value logger.debug(f"Hash gesetzt für {xml_file.xml}: {hash_value}") except Exception as e: logger.error(f"Fehler beim Setzen des Hash-Werts: {e}") def _on_hash_calculation_finished(self, processed_count: int, total_count: int): """ Wird aufgerufen, wenn die Hash-Berechnung abgeschlossen ist. Args: processed_count: Anzahl der verarbeiteten Dateien total_count: Gesamtanzahl der Dateien """ try: logger.info(f"Hash-Berechnung abgeschlossen: {processed_count}/{total_count} Dateien verarbeitet") # Speichere die aktualisierten Projekt-Einstellungen if processed_count > 0: self._save_project_settings() logger.info("Projekt-Einstellungen mit neuen Hash-Werten gespeichert") except Exception as e: logger.error(f"Fehler beim Abschließen der Hash-Berechnung: {e}") def _on_hash_calculation_error(self, xml_file_path: str, error_message: str): """ Wird aufgerufen, wenn ein Fehler bei der Hash-Berechnung auftritt. Args: xml_file_path: Pfad zur XML-Datei error_message: Fehlermeldung """ logger.warning(f"Hash-Berechnungsfehler für {xml_file_path}: {error_message}") def _calculate_hash_for_xml_file(self, xml_file: XmlFile): """ Berechnet synchron den Hash für eine einzelne XML-Datei. Wird verwendet beim Hinzufügen neuer XML-Dateien. Args: xml_file: Das XmlFile-Objekt """ try: if xml_file.hashsum: logger.debug(f"Hash bereits vorhanden für {xml_file.xml}: {xml_file.hashsum}") return # Prüfe ob Projekt verfügbar ist if not self.project or not self.project.project_dir: logger.warning("Kein Projekt-Verzeichnis für Hash-Berechnung verfügbar") return xml_file_path = Path(self.project.project_dir) / xml_file.xml if not xml_file_path.exists(): logger.warning(f"XML-Datei nicht gefunden: {xml_file_path}") return # Datei binär lesen und Hash berechnen with open(xml_file_path, "rb") as f: file_content = f.read() hash_obj = hashlib.blake2b(file_content) hash_hex = hash_obj.hexdigest() # Hash mit Präfix setzen xml_file.hashsum = f"blake2b:{hash_hex}" logger.debug(f"Hash berechnet für {xml_file.xml}: {xml_file.hashsum}") except Exception as e: logger.error(f"Fehler beim Berechnen des Hash für {xml_file.xml}: {e}") def _get_all_project_xml_files(self) -> List[XmlFile]: """ Sammelt alle XmlFile-Objekte aus dem gesamten Projekt für Hash-Vergleiche. Returns: List[XmlFile]: Liste aller XML-Dateien im Projekt """ xml_files = [] try: if self.pdf_project and self.pdf_project.nodes: self._collect_xml_files_for_hash_comparison(self.pdf_project.nodes, xml_files) logger.debug(f"Hash-Vergleich: {len(xml_files)} XML-Dateien im Projekt gefunden") return xml_files except Exception as e: logger.error(f"Fehler beim Sammeln der XML-Dateien für Hash-Vergleich: {e}") return [] def _collect_xml_files_for_hash_comparison(self, nodes, xml_files: List[XmlFile]): """ Sammelt rekursiv alle XML-Dateien aus den Nodes für Hash-Vergleiche. Args: nodes: Liste der zu durchsuchenden Nodes xml_files: Liste zum Sammeln der XML-Dateien """ for node in nodes: if isinstance(node, XslFile) and node.xmls: # Füge alle XML-Dateien dieser XSL-Datei hinzu for xml_file in node.xmls: # Vermeide Duplikate basierend auf Pfad if not any(existing.xml == xml_file.xml for existing in xml_files): xml_files.append(xml_file) elif isinstance(node, TreeNode) and node.children: # Rekursiv in Kinder-Nodes suchen self._collect_xml_files_for_hash_comparison(node.children, xml_files) def _find_xml_file_by_hash(self, target_hash: str) -> XmlFile | None: """ Sucht eine XML-Datei mit dem angegebenen Hash im gesamten Projekt. Args: target_hash: Der zu suchende Hash-Wert (mit blake2b: Präfix) Returns: XmlFile|None: Die gefundene XML-Datei oder None """ try: if not target_hash: return None all_xml_files = self._get_all_project_xml_files() for xml_file in all_xml_files: if xml_file.hashsum == target_hash: logger.debug(f"Hash-Match gefunden: {xml_file.xml} hat Hash {target_hash}") return xml_file logger.debug(f"Kein Hash-Match für {target_hash} gefunden") return None except Exception as e: logger.error(f"Fehler bei Hash-Suche für {target_hash}: {e}") return None def _generate_alternative_filename(self, original_path: Path, xml_dir: Path) -> Path: """ Generiert alternative Dateinamen im Format: datei_1.xml, datei_2.xml, ... Args: original_path: Ursprünglicher Dateipfad xml_dir: Ziel-XML-Verzeichnis Returns: Path: Pfad mit alternativem Dateinamen """ try: base_name = original_path.stem # "datei" extension = original_path.suffix # ".xml" # Sammle einmalig alle verwendeten Dateinamen (Performance-Optimierung) all_xml_files = self._get_all_project_xml_files() used_names = {xml_file.xml.name for xml_file in all_xml_files} counter = 1 while True: new_name = f"{base_name}_{counter}{extension}" new_path = xml_dir / new_name # Prüfe sowohl physische Existenz als auch Verwendung im Projekt (optimierter Set-Lookup) if not new_path.exists() and new_name not in used_names: logger.debug(f"Alternativer Dateiname generiert: {new_name}") return new_path counter += 1 # Sicherheitsgrenze um Endlosschleifen zu vermeiden if counter > 1000: raise Exception("Zu viele alternative Dateinamen generiert") except Exception as e: logger.error(f"Fehler beim Generieren alternativer Dateinamen für {original_path}: {e}") # Fallback: Zeitstempel verwenden timestamp = int(time.time()) fallback_name = f"{original_path.stem}_{timestamp}{original_path.suffix}" return xml_dir / fallback_name def _is_filename_used_in_project(self, relative_xml_path: Path) -> bool: """ Prüft ob ein relativer XML-Dateipfad bereits im Projekt verwendet wird. Args: relative_xml_path: Relativer Pfad zur XML-Datei (z.B. xml/datei_1.xml) Returns: bool: True wenn der Dateiname bereits verwendet wird """ try: all_xml_files = self._get_all_project_xml_files() for xml_file in all_xml_files: if xml_file.xml == relative_xml_path: return True return False except Exception as e: logger.error(f"Fehler beim Prüfen der Dateiname-Verwendung für {relative_xml_path}: {e}") return True # Im Zweifelsfall annehmen, dass der Name verwendet wird def _calculate_hash_for_file(self, file_path: Path) -> str | None: """ Berechnet synchron den blake2b-Hash für eine Datei. Args: file_path: Pfad zur Datei Returns: str|None: Hash-Wert mit blake2b: Präfix oder None bei Fehler """ try: if not file_path.exists(): logger.warning(f"Datei für Hash-Berechnung nicht gefunden: {file_path}") return None # Datei binär lesen und Hash berechnen with open(file_path, "rb") as f: file_content = f.read() hash_obj = hashlib.blake2b(file_content) hash_hex = hash_obj.hexdigest() # Hash mit Präfix zurückgeben hash_value = f"blake2b:{hash_hex}" logger.debug(f"Hash berechnet für {file_path}: {hash_value}") return hash_value except Exception as e: logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}") return None def _assign_existing_xml_to_nodes(self, existing_xml: XmlFile, selected_xsl_nodes: list): """ Ordnet eine bereits vorhandene XML-Datei (basierend auf Hash-Match) den XSL-Knoten zu. Args: existing_xml: Die bereits vorhandene XML-Datei selected_xsl_nodes: Liste der ausgewählten XSL-Knoten """ try: added_count = 0 for xsl_node in selected_xsl_nodes: # Prüfe ob diese XML-Datei bereits in der XslFile-Node vorhanden ist already_assigned = any(xml_file.xml == existing_xml.xml for xml_file in xsl_node.xmls) if not already_assigned: # Erstelle neue XmlFile-Referenz mit gleichem Pfad und Hash new_xml_ref = XmlFile(xml=existing_xml.xml, hashsum=existing_xml.hashsum) xsl_node.xmls.append(new_xml_ref) added_count += 1 logger.info(f"Vorhandene XML-Datei '{existing_xml.xml}' zu XSL-Node '{xsl_node.bez}' zugeordnet") else: logger.debug(f"XML-Datei '{existing_xml.xml}' bereits in XSL-Node '{xsl_node.bez}' vorhanden") if added_count > 0: # Speichere die aktualisierten Projekt-Einstellungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() # Zeige Erfolgsmeldung QMessageBox.information( self, "XML-Datei zugeordnet", f"Eine XML-Datei mit gleichem Inhalt war bereits im Projekt vorhanden.\n\n" f"Die vorhandene Datei '{existing_xml.xml.name}' wurde automatisch zu {added_count} XSL-Knoten zugeordnet.", ) else: QMessageBox.information( self, "Bereits zugeordnet", "Die XML-Datei mit gleichem Inhalt ist bereits in allen ausgewählten XSL-Knoten vorhanden.", ) except Exception as e: error_msg = f"Fehler beim Zuordnen der vorhandenen XML-Datei: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _process_new_xml_file(self, xml_file_path: Path, selected_xsl_nodes: list, file_hash: str | None): """ Verarbeitet eine neue XML-Datei (kein Hash-Match gefunden). Args: xml_file_path: Pfad zur neuen XML-Datei selected_xsl_nodes: Liste der ausgewählten XSL-Knoten file_hash: Berechneter Hash der Datei """ try: # Prüfe ob Projekt verfügbar ist if not self.project or not self.project.project_dir: logger.error("Kein Projekt-Verzeichnis für neue XML-Datei verfügbar") QMessageBox.critical(self, "Fehler", "Kein Projekt-Verzeichnis verfügbar.") 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() or self._is_filename_used_in_project(Path("xml") / xml_file_path.name): # Generiere alternative Dateinamen alternative_paths = [] for i in range(1, 6): # Generiere bis zu 5 Alternativen alt_path = self._generate_alternative_filename(xml_file_path, xml_dir) if alt_path not in alternative_paths: alternative_paths.append(alt_path) # Zeige Dialog zur Auswahl des Dateinamens selected_path = self._show_filename_selection_dialog(xml_file_path.name, alternative_paths) if not selected_path: # Benutzer hat abgebrochen return target_xml_path = selected_path # 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 Projekt-Verzeichnis) relative_xml_path = Path("xml") / target_xml_path.name # Füge die XML-Datei zu allen ausgewählten XSL-Knoten hinzu added_count = 0 for xsl_node in selected_xsl_nodes: # 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 not existing_xml: # Erstelle neues XmlFile-Objekt mit Hash new_xml_file = XmlFile(xml=relative_xml_path, hashsum=file_hash) xsl_node.xmls.append(new_xml_file) added_count += 1 logger.info(f"XML-Datei '{target_xml_path.name}' zu XSL-Node '{xsl_node.bez}' hinzugefügt") else: logger.debug(f"XML-Datei '{target_xml_path.name}' bereits in XSL-Node '{xsl_node.bez}' vorhanden") if added_count > 0: # Speichere die aktualisierten Projekt-Einstellungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() # Zeige Erfolgsmeldung success_msg = ( f"XML-Datei '{target_xml_path.name}' wurde erfolgreich zu {added_count} XSL-Knoten hinzugefügt." ) if target_xml_path.name != xml_file_path.name: success_msg += ( f"\n\nDie Datei wurde umbenannt von '{xml_file_path.name}' zu '{target_xml_path.name}'." ) QMessageBox.information(self, "Erfolg", success_msg) else: QMessageBox.information( self, "Information", f"XML-Datei '{target_xml_path.name}' war bereits in allen ausgewählten XSL-Knoten vorhanden.", ) except Exception as e: error_msg = f"Fehler beim Verarbeiten der neuen XML-Datei: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _show_filename_selection_dialog(self, original_name: str, alternative_paths: List[Path]) -> Path | None: """ Zeigt einen Dialog zur Auswahl eines alternativen Dateinamens. Args: original_name: Ursprünglicher Dateiname alternative_paths: Liste alternativer Pfade Returns: Path|None: Ausgewählter Pfad oder None bei Abbruch """ try: from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QRadioButton, QButtonGroup, QPushButton, QHBoxLayout, ) dialog = QDialog(self) dialog.setWindowTitle("Dateiname auswählen") dialog.setModal(True) dialog.resize(400, 300) layout = QVBoxLayout(dialog) # Erklärungstext info_label = QLabel( f"Eine Datei mit dem Namen '{original_name}' existiert bereits.\n\n" "Bitte wählen Sie einen alternativen Dateinamen:" ) layout.addWidget(info_label) # Radio-Buttons für alternative Namen button_group = QButtonGroup(dialog) radio_buttons = [] for i, alt_path in enumerate(alternative_paths): radio_button = QRadioButton(alt_path.name) if i == 0: # Ersten als Standard auswählen radio_button.setChecked(True) button_group.addButton(radio_button, i) radio_buttons.append(radio_button) layout.addWidget(radio_button) # Buttons button_layout = QHBoxLayout() ok_button = QPushButton("OK") cancel_button = QPushButton("Abbrechen") button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) # Event-Handler ok_button.clicked.connect(dialog.accept) cancel_button.clicked.connect(dialog.reject) # Dialog anzeigen if dialog.exec() == QDialog.DialogCode.Accepted: selected_id = button_group.checkedId() if 0 <= selected_id < len(alternative_paths): return alternative_paths[selected_id] return None except Exception as e: logger.error(f"Fehler beim Anzeigen des Dateiname-Auswahl-Dialogs: {e}") # Fallback: Ersten alternativen Namen verwenden return alternative_paths[0] if alternative_paths else None def _transform_xml_file(self, item: QTreeWidgetItem, force: bool = False): """ Transformiert eine einzelne XML-Datei. Args: item: Das TreeWidgetItem der XML-Datei force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt """ try: # Hole XslFile vom Parent-Item parent_item = item.parent() if not parent_item: logger.error("XML-Datei hat kein Parent-Item (XslFile)") QMessageBox.warning(self, "Fehler", "XML-Datei hat keine zugeordnete XSL-Datei") return xsl_file_obj = parent_item.data(0, Qt.ItemDataRole.UserRole) if not isinstance(xsl_file_obj, XslFile): logger.error(f"Parent-Item ist kein XslFile: {type(xsl_file_obj)}") QMessageBox.warning(self, "Fehler", "Konnte XSL-Datei nicht ermitteln") return # Hole XmlFile-Objekt xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole) if not isinstance(xml_file_obj, XmlFile): logger.error(f"Item ist kein XmlFile: {type(xml_file_obj)}") QMessageBox.warning(self, "Fehler", "Konnte XML-Datei nicht ermitteln") return # Erstelle TransformationJob job = self._create_transformation_job(xsl_file_obj, xml_file_obj) if not job: return # Starte Transformation in separatem Thread self._start_transformation([job], force=force) except Exception as e: logger.error(f"Fehler beim Transformieren der XML-Datei: {e}") QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}") def _transform_xsl_file(self, item: QTreeWidgetItem, force: bool = False): """ Transformiert alle XML-Dateien einer XSL-Datei. Args: item: Das TreeWidgetItem der XSL-Datei force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt """ try: # Hole XslFile-Objekt xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole) if not isinstance(xsl_file_obj, XslFile): logger.error(f"Item ist kein XslFile: {type(xsl_file_obj)}") QMessageBox.warning(self, "Fehler", "Konnte XSL-Datei nicht ermitteln") return # Prüfe ob XML-Dateien vorhanden sind if not xsl_file_obj.xmls: QMessageBox.information(self, "Info", "Keine XML-Dateien zugeordnet") return # Erstelle TransformationJobs für alle XML-Dateien jobs = [] for xml_file_obj in xsl_file_obj.xmls: job = self._create_transformation_job(xsl_file_obj, xml_file_obj) if job: jobs.append(job) if not jobs: QMessageBox.warning(self, "Fehler", "Konnte keine Transformations-Jobs erstellen") return # Starte Transformation in separatem Thread self._start_transformation(jobs, force=force) except Exception as e: logger.error(f"Fehler beim Transformieren der XSL-Datei: {e}") QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}") def _create_transformation_job(self, xsl_file_obj: XslFile, xml_file_obj: XmlFile) -> TransformationJob | None: """ Erstellt einen TransformationJob für eine XML/XSL-Kombination. Args: xsl_file_obj: Das XslFile-Objekt xml_file_obj: Das XmlFile-Objekt Returns: TransformationJob oder None bei Fehler """ try: if not self.project: QMessageBox.warning(self, "Fehler", "Kein Projekt geöffnet") return None # Hole Tool-Konfigurationen aus app_settings java_vm = next((jvm for jvm in app_settings.java_vms if jvm.id == self.project.java_vm_id), None) saxon_jar = next((sj for sj in app_settings.saxon_jars if sj.id == self.project.saxon_jar_id), None) apache_fop = next((af for af in app_settings.apache_fops if af.id == self.project.apache_fop_id), None) diff_pdf = next((dp for dp in app_settings.diff_pdfs if dp.id == self.project.diff_pdf_id), None) xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None) # Prüfe ob alle Konfigurationen vorhanden sind if not all([java_vm, saxon_jar, apache_fop, diff_pdf, xsl_dir]): missing = [] if not java_vm: missing.append("Java VM") if not saxon_jar: missing.append("Saxon JAR") if not apache_fop: missing.append("Apache FOP") if not diff_pdf: missing.append("diff-pdf") if not xsl_dir: missing.append("XSL-Verzeichnis") QMessageBox.warning( self, "Fehlende Konfiguration", f"Folgende Konfigurationen fehlen: {', '.join(missing)}" ) return None # Erstelle absoluten Pfad zur XSL-Datei xsl_file_abs = xsl_dir.path_to_root_dir / xsl_file_obj.xsl_file # Sammle XSLT-Parameter (kombiniere TreeNode + XslFile Parameter) xslt_params = {} # TODO: Hier könnten TreeNode-Parameter von übergeordneten Knoten gesammelt werden xslt_params.update(xsl_file_obj.xslt_params) # Erstelle TransformationJob job = TransformationJob( project_dir=self.project.project_dir, xml_file=xml_file_obj.xml, xsl_file=xsl_file_abs, xslt_params=xslt_params, java_vm_path=java_vm.path_to_binary_file, saxon_jar_path=saxon_jar.path_to_jar_file, apache_fop_dir=apache_fop.path_to_dir, diff_pdf_path=diff_pdf.path_to_binary_file, diff_pdf_params=diff_pdf.default_params, xsl_id=xsl_file_obj.id, ) return job except Exception as e: logger.error(f"Fehler beim Erstellen des TransformationJobs: {e}") QMessageBox.critical(self, "Fehler", f"Fehler beim Erstellen des Jobs: {str(e)}") return None def _start_transformation(self, jobs: list[TransformationJob], force: bool = False): """ Startet die Transformation in einem separaten Thread. Args: jobs: Liste der TransformationJobs force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date) """ try: # Prüfe ob bereits ein Thread läuft if self.transformation_thread and self.transformation_thread.isRunning(): QMessageBox.warning(self, "Warnung", "Es läuft bereits eine Transformation") return # Erstelle und konfiguriere Thread self.transformation_thread = TransformationThread(jobs, force=force) # Verbinde Signale self.transformation_thread.job_started.connect(self._on_transformation_job_started) self.transformation_thread.job_finished.connect(self._on_transformation_job_finished) self.transformation_thread.job_error.connect(self._on_transformation_job_error) self.transformation_thread.all_jobs_finished.connect(self._on_all_transformations_finished) # Starte Thread self.transformation_thread.start() logger.info(f"Transformation von {len(jobs)} Job(s) gestartet (force={force})") self.statusBar().showMessage(f"Transformation von {len(jobs)} Job(s) gestartet...") except Exception as e: logger.error(f"Fehler beim Starten der Transformation: {e}") QMessageBox.critical(self, "Fehler", f"Fehler beim Starten: {str(e)}") def _on_transformation_job_started(self, xml_file_name: str, xsl_id_str: str): """ Signal-Handler: Ein Job wurde gestartet. Args: xml_file_name: Name der XML-Datei xsl_id_str: XSL-ID als String (z.B. "2002_1_128") """ logger.info(f"Transformation gestartet: {xml_file_name} (XSL-ID: {xsl_id_str})") self.statusBar().showMessage(f"Transformiere: {xml_file_name}") # Progress Bar anzeigen map_key = f"{xml_file_name}|{xsl_id_str}" if map_key not in self.xml_item_map and self.xml_item_map: # Zeige erste Keys zur Diagnose sample_keys = list(self.xml_item_map.keys())[:3] logger.info(f"Suche TreeWidget-Item für: '{map_key}'") logger.info(f"Map hat {len(self.xml_item_map)} Einträge") tree_item = self.xml_item_map.get(map_key) if tree_item: # Entferne vorhandenes Widget (falls Icon vorhanden) self.ui.treeWidget.removeItemWidget(tree_item, 2) # Erstelle und setze Progress Bar progress_widget, progress_bar = self._create_centered_progress_bar() self.ui.treeWidget.setItemWidget(tree_item, 2, progress_widget) logger.debug(f"Progress Bar für {xml_file_name} gesetzt") else: logger.warning(f"Kein TreeWidget-Item für {xml_file_name} gefunden") def _on_transformation_job_finished(self, result: dict): """ Signal-Handler: Ein Job wurde abgeschlossen. Args: result: Ergebnis-Dictionary """ xml_file = result.get("xml_file", "?") success = result.get("success", False) duration = result.get("duration", 0) if success: logger.info(f"Transformation erfolgreich: {xml_file} ({duration:.2f}s)") pdfs_identical = result.get("pdfs_identical", False) if pdfs_identical: self.statusBar().showMessage(f"✓ {xml_file} - PDFs identisch ({duration:.2f}s)", 3000) else: self.statusBar().showMessage(f"⚠ {xml_file} - Unterschiede gefunden ({duration:.2f}s)", 3000) else: logger.error(f"Transformation fehlgeschlagen: {xml_file}") # Zeige Fehlerdetails an steps = result.get("steps", {}) error_msgs = [] for step_name, step_info in steps.items(): if not step_info.get("success", True): error_msgs.append(f"{step_name}: {step_info.get('message', 'Unbekannter Fehler')}") error_text = "\n".join(error_msgs) if error_msgs else "Unbekannter Fehler" QMessageBox.critical( self, "Transformation fehlgeschlagen", f"XML-Datei: {xml_file}\n\nFehler:\n{error_text}" ) # Update Widget in Spalte 2: Entferne Progress Bar, zeige Icon wenn Diff-PDF existiert xml_file_str = result.get("xml_file", "") xsl_id = result.get("xsl_id", None) xsl_id_str = "_".join(str(x) for x in xsl_id) if xsl_id else "" map_key = f"{xml_file_str}|{xsl_id_str}" diff_pdf_str = result.get("diff_pdf", None) tree_item = self.xml_item_map.get(map_key) if tree_item: # Entferne Progress Bar self.ui.treeWidget.removeItemWidget(tree_item, 2) # Wenn Diff-PDF existiert, zeige Icon if diff_pdf_str and Path(diff_pdf_str).exists(): xml_file_path = Path(xml_file_str) icon_widget = self._create_centered_diff_icon(xml_file_path) self.ui.treeWidget.setItemWidget(tree_item, 2, icon_widget) logger.debug(f"Diff-Icon für {xml_file_str} gesetzt") else: logger.debug(f"Keine Diff-PDF für {xml_file_str}, kein Icon gesetzt") def _on_transformation_job_error(self, xml_file_name: str, xsl_id_str: str, error_message: str): """ Signal-Handler: Ein Job ist mit einem Fehler abgebrochen. Args: xml_file_name: Name der XML-Datei xsl_id_str: XSL-ID als String error_message: Fehlermeldung """ logger.error(f"Transformation-Fehler bei {xml_file_name} (XSL-ID: {xsl_id_str}): {error_message}") QMessageBox.critical(self, "Fehler", f"Fehler bei {xml_file_name}:\n{error_message}") # Entferne Progress Bar bei Fehler map_key = f"{xml_file_name}|{xsl_id_str}" tree_item = self.xml_item_map.get(map_key) if tree_item: self.ui.treeWidget.removeItemWidget(tree_item, 2) logger.debug(f"Progress Bar für {map_key} entfernt (Fehler)") def _on_all_transformations_finished(self, successful_count: int, total_count: int): """ Signal-Handler: Alle Jobs wurden abgeschlossen. Args: successful_count: Anzahl erfolgreicher Jobs total_count: Gesamtanzahl der Jobs """ logger.info(f"Alle Transformationen abgeschlossen: {successful_count}/{total_count} erfolgreich") if successful_count == total_count: self.statusBar().showMessage(f"✓ Alle {total_count} Transformationen erfolgreich", 5000) QMessageBox.information( self, "Abgeschlossen", f"Alle {total_count} Transformationen wurden erfolgreich abgeschlossen" ) else: failed_count = total_count - successful_count self.statusBar().showMessage( f"⚠ {successful_count}/{total_count} erfolgreich, {failed_count} fehlgeschlagen", 5000 ) QMessageBox.warning( self, "Abgeschlossen mit Fehlern", f"{successful_count} von {total_count} Transformationen erfolgreich\n{failed_count} fehlgeschlagen", ) def closeEvent(self, event): """Wird beim Schließen der Anwendung aufgerufen.""" # Stoppe Hash-Berechnungs-Thread falls noch aktiv if ( hasattr(self, "hash_calculator_thread") and self.hash_calculator_thread and self.hash_calculator_thread.isRunning() ): self.hash_calculator_thread.quit() self.hash_calculator_thread.wait() # Stoppe Transformations-Thread falls noch aktiv if ( hasattr(self, "transformation_thread") and self.transformation_thread and self.transformation_thread.isRunning() ): self.transformation_thread.quit() self.transformation_thread.wait() # PDF-Dokumente schließen ist bei QtPdf automatisch durch Garbage Collection super().closeEvent(event)