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, set_saxon_worker_pool from saxon_pool import SaxonWorkerPool 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 XmlBatchProcessingThread(QThread): """ Thread für die asynchrone Batch-Verarbeitung von mehreren XML-Dateien. Verarbeitet XML-Dateien mit Hash-Berechnung, Duplikatserkennung und Dateikopieren. """ # Signale für die Kommunikation mit dem Haupt-Thread progress_update = Signal(int, int, str) # current, total, current_file_name file_processed = Signal(dict) # result dictionary processing_finished = Signal(dict) # stats dictionary error_occurred = Signal(str) # error_message def __init__(self, xml_files: list, selected_xsl_nodes: list, project_dir: Path, pdf_project): """ Initialisiert den Batch-Verarbeitungs-Thread. Args: xml_files: Liste von Pfaden zu XML-Dateien selected_xsl_nodes: Liste der ausgewählten XSL-Knoten project_dir: Pfad zum Projekt-Verzeichnis pdf_project: ProjectData-Objekt """ super().__init__() self.xml_files = xml_files self.selected_xsl_nodes = selected_xsl_nodes self.project_dir = project_dir self.pdf_project = pdf_project # Statistiken self.stats = { "total": len(xml_files), "processed": 0, "new_added": 0, "existing_added": 0, "already_assigned": 0, "cancelled": 0, "errors": 0, "error_messages": [], "renamed_files": [], } def run(self): """ Führt die Batch-Verarbeitung aller XML-Dateien aus. """ logger.info(f"Starte Batch-Verarbeitung für {len(self.xml_files)} XML-Dateien") for i, xml_file_path in enumerate(self.xml_files): try: # Sende Progress-Update self.progress_update.emit(i + 1, len(self.xml_files), xml_file_path.name) # Prüfe ob die Datei existiert if not xml_file_path.exists(): self.stats["errors"] += 1 self.stats["error_messages"].append(f"{xml_file_path.name}: Datei existiert nicht") continue # Verarbeite die XML-Datei result = self._process_xml_file(xml_file_path) # Aktualisiere Statistiken self._update_stats(result) # Sende Ergebnis self.file_processed.emit(result) except Exception as e: error_msg = f"Fehler bei {xml_file_path.name}: {str(e)}" logger.error(error_msg) self.stats["errors"] += 1 self.stats["error_messages"].append(error_msg) # Sende Abschluss-Signal mit Statistiken self.processing_finished.emit(self.stats) logger.info(f"Batch-Verarbeitung abgeschlossen: {self.stats['processed']}/{self.stats['total']} verarbeitet") def _process_xml_file(self, xml_file_path: Path) -> dict: """ Verarbeitet eine einzelne XML-Datei. Args: xml_file_path: Pfad zur XML-Datei Returns: dict: Ergebnis-Dictionary mit Status """ try: # 1. Hash 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 auf Hash-Duplikat existing_xml = self._find_xml_file_by_hash(file_hash) if file_hash else None if existing_xml: # Hash-Match: Ordne vorhandene Datei zu return self._assign_existing_xml_to_nodes(existing_xml) else: # Keine Duplikate: Verarbeite als neue Datei return self._process_new_xml_file(xml_file_path, file_hash) except Exception as e: return {"status": "error", "error_msg": str(e)} def _calculate_hash_for_file(self, file_path: Path) -> str | None: """Berechnet blake2b Hash für eine Datei.""" try: if not file_path.exists(): return None with open(file_path, "rb") as f: file_content = f.read() hash_obj = hashlib.blake2b(file_content) hash_hex = hash_obj.hexdigest() return f"blake2b:{hash_hex}" except Exception as e: logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}") return None def _find_xml_file_by_hash(self, hash_value: str) -> XmlFile | None: """Sucht eine XML-Datei anhand ihres Hash-Werts.""" if not hash_value or not self.pdf_project.nodes: return None def search_recursive(nodes): for node in nodes: if isinstance(node, XslFile) and node.xmls: for xml_file in node.xmls: if xml_file.hashsum == hash_value: return xml_file elif isinstance(node, TreeNode) and node.children: found = search_recursive(node.children) if found: return found return None return search_recursive(self.pdf_project.nodes) def _assign_existing_xml_to_nodes(self, existing_xml: XmlFile) -> dict: """Ordnet eine vorhandene XML-Datei den Knoten zu.""" try: added_count = 0 for xsl_node in self.selected_xsl_nodes: already_assigned = any(xml_file.xml == existing_xml.xml for xml_file in xsl_node.xmls) if not already_assigned: new_xml_ref = XmlFile(xml=existing_xml.xml, hashsum=existing_xml.hashsum) xsl_node.xmls.append(new_xml_ref) added_count += 1 if added_count > 0: return { "status": "existing_added", "added_count": added_count, "existing_file": existing_xml.xml.name, } else: return {"status": "already_assigned", "added_count": 0, "existing_file": existing_xml.xml.name} except Exception as e: return {"status": "error", "error_msg": str(e)} def _process_new_xml_file(self, xml_file_path: Path, file_hash: str | None) -> dict: """Verarbeitet eine neue XML-Datei.""" try: # Erstelle xml-Ordner xml_dir = self.project_dir / "xml" xml_dir.mkdir(parents=True, exist_ok=True) # Bestimme Ziel-Pfad target_xml_path = xml_dir / xml_file_path.name # Prüfe auf Namenskonflikte und generiere ggf. alternativen Namen original_name = xml_file_path.name counter = 1 while target_xml_path.exists() or self._is_filename_used_in_project(Path("xml") / target_xml_path.name): # Generiere alternativen Namen stem = xml_file_path.stem suffix = xml_file_path.suffix target_xml_path = xml_dir / f"{stem}_{counter}{suffix}" counter += 1 # Sicherheit: Maximal 1000 Versuche if counter > 1000: return {"status": "error", "error_msg": "Konnte keinen eindeutigen Dateinamen finden"} # Kopiere Datei shutil.copy2(xml_file_path, target_xml_path) # Erstelle relatives Path relative_xml_path = Path("xml") / target_xml_path.name # Füge zu XSL-Knoten hinzu added_count = 0 for xsl_node in self.selected_xsl_nodes: existing_xml = any(xml_file.xml == relative_xml_path for xml_file in xsl_node.xmls) if not existing_xml: new_xml_file = XmlFile(xml=relative_xml_path, hashsum=file_hash) xsl_node.xmls.append(new_xml_file) added_count += 1 if added_count > 0: return { "status": "new_added", "added_count": added_count, "new_file": target_xml_path.name, "renamed_from": original_name if target_xml_path.name != original_name else None, } else: return {"status": "already_assigned", "added_count": 0, "new_file": target_xml_path.name} except Exception as e: return {"status": "error", "error_msg": str(e)} def _is_filename_used_in_project(self, filename: Path) -> bool: """Prüft ob ein Dateiname bereits im Projekt verwendet wird.""" if not self.pdf_project.nodes: return False def search_recursive(nodes): for node in nodes: if isinstance(node, XslFile) and node.xmls: for xml_file in node.xmls: if xml_file.xml == filename: return True elif isinstance(node, TreeNode) and node.children: if search_recursive(node.children): return True return False return search_recursive(self.pdf_project.nodes) def _update_stats(self, result: dict): """Aktualisiert die Statistiken.""" self.stats["processed"] += 1 status = result.get("status") if status == "new_added": self.stats["new_added"] += 1 if result.get("renamed_from"): self.stats["renamed_files"].append(f"{result['renamed_from']} → {result['new_file']}") elif status == "existing_added": self.stats["existing_added"] += 1 elif status == "already_assigned": self.stats["already_assigned"] += 1 elif status == "error": self.stats["errors"] += 1 self.stats["error_messages"].append(result.get("error_msg", "Unbekannter Fehler")) 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, float) # successful_count, total_count, total_duration def __init__(self, jobs: list[TransformationJob], force: bool = False, max_workers: int = 8): """ Initialisiert den Transformations-Thread. Args: jobs: Liste der TransformationJob-Objekte force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date) max_workers: Maximale Anzahl paralleler Worker (Standard: 8) """ super().__init__() self.jobs = jobs self.force = force self.max_workers = max_workers self.successful_count = 0 def _process_single_job(self, job: TransformationJob) -> dict: """ Verarbeitet einen einzelnen Transformations-Job (Thread-safe). Args: job: Der zu verarbeitende TransformationJob Returns: dict: Ergebnis-Dictionary des 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) return result 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) return {"success": False, "error": error_msg} def run(self): """ Führt alle Transformations-Jobs parallel aus mit ThreadPoolExecutor. """ from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime import threading start_time = datetime.now() logger.info(f"Starte parallele Transformation von {len(self.jobs)} Jobs mit {self.max_workers} Workern") # Thread-sicherer Counter successful_lock = threading.Lock() # Verwende ThreadPoolExecutor für parallele Verarbeitung with ThreadPoolExecutor(max_workers=self.max_workers) as executor: # Starte alle Jobs future_to_job = {executor.submit(self._process_single_job, job): job for job in self.jobs} # Warte auf Abschluss und sammle Ergebnisse for future in as_completed(future_to_job): try: result = future.result() if result.get("success", False): with successful_lock: self.successful_count += 1 except Exception as e: logger.error(f"Fehler beim Verarbeiten des Future: {e}") # Berechne Gesamtdauer total_duration = (datetime.now() - start_time).total_seconds() # Sende Abschluss-Signal für alle Jobs mit Gesamtdauer self.all_jobs_finished.emit(self.successful_count, len(self.jobs), total_duration) logger.info( f"Transformation abgeschlossen: {self.successful_count}/{len(self.jobs)} erfolgreich ({total_duration:.2f}s) " f"[{len(self.jobs) / total_duration:.2f} Jobs/s mit {self.max_workers} Workern]" ) 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 # Aktuelle Diff-PDF-Informationen (für Accept Changes) self.current_diff_xml_path = None self.current_diff_xsl_id = 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 # Batch-Processing-Thread für XML-Dateien self.batch_processing_thread = None # Progressbar für Batch-Verarbeitung in Statusbar self.batch_progress_bar = None # Progressbar für Transformationen in Statusbar self.transformation_progress_bar = 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() # Performance-Einstellungen-Menü initialisieren self._setup_performance_menu() # if theme := app_settings.theme: self.change_theme(theme) else: self.change_theme("Fusion") # 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() # Gespeicherte UI-Zustände wiederherstellen self._restore_ui_state() def _restore_ui_state(self): """Stellt die gespeicherten UI-Zustände wieder her (Fenstergeometrie, Splitter, TreeWidget-Spalten).""" global app_settings # Fenstergeometrie wiederherstellen if app_settings.window_geometry: x, y, width, height = app_settings.window_geometry self.setGeometry(x, y, width, height) # Splitter-Positionen wiederherstellen if app_settings.splitter_sizes: self.ui.splitter.setSizes(app_settings.splitter_sizes) # TreeWidget-Spaltenbreiten wiederherstellen if app_settings.tree_column_widths: for col_idx, width in enumerate(app_settings.tree_column_widths): if col_idx < self.ui.treeWidget.columnCount(): self.ui.treeWidget.setColumnWidth(col_idx, width) 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() logger.debug(f"Verfügbare Themes: {available_themes}") logger.debug(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) logger.info(f"Projekte-Menü initialisiert mit {len(app_settings.pdf_projects)} Projekten") def _setup_performance_menu(self): """Fügt ein Menü-Item für Performance-Einstellungen hinzu.""" # Füge Separator vor der Performance-Einstellung hinzu self.ui.menuProjekt.addSeparator() # Erstelle Aktion für Performance-Einstellungen performance_action = QAction("Performance-Einstellungen...", self) performance_action.setToolTip(f"Parallele Worker: {app_settings.max_workers}") performance_action.triggered.connect(self._open_performance_settings) # Füge die Aktion zum Projekt-Menü hinzu self.ui.menuProjekt.addAction(performance_action) logger.debug(f"Performance-Menü initialisiert (max_workers={app_settings.max_workers})") def _open_performance_settings(self): """Öffnet einen Dialog für Performance-Einstellungen.""" from PySide6.QtWidgets import QInputDialog current_workers = app_settings.max_workers new_workers, ok = QInputDialog.getInt( self, "Performance-Einstellungen", "Anzahl paralleler Worker für Transformationen:", current_workers, # value 1, # minValue 32, # maxValue 1, # step ) if ok and new_workers != current_workers: app_settings.max_workers = new_workers app_settings.save() logger.info(f"max_workers geändert: {current_workers} → {new_workers}") QMessageBox.information( self, "Einstellungen gespeichert", f"Anzahl paralleler Worker wurde auf {new_workers} gesetzt.\n\n" f"Die Änderung wird bei der nächsten Transformation wirksam.", ) def open_existing_project(self, project: Project): """ Öffnet ein vorhandenes Projekt. Args: project: Das zu öffnende PdfProject-Objekt """ logger.info(f"Öffne Projekt: {project.name}") logger.debug(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) logger.info(f"Projekt-Einstellungen aus {project_yaml_path} geladen!") else: # Erstelle Standard-Projekt-Einstellungen wenn Datei leer oder nicht vorhanden logger.warning("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) logger.info(f"Standard-Projekt-Einstellungen in {project_yaml_path} gespeichert") # Lade die Nodes in das TreeWidget (inkl. Diff-PDF-Counts und Icons) self._load_nodes_to_tree() # Starte Hash-Berechnung für alle XML-Dateien self._start_xml_hash_calculation() # Initialisiere Saxon-Worker-Pool für schnellere Transformationen self._initialize_saxon_worker_pool() except Exception as e: logger.error(f"Fehler beim Laden des Projekts '{project.name}': {e}") # Fallback: Erstelle Standard-Einstellungen try: self.pdf_project = ProjectData() logger.info("Fallback: Standard-Projekt-Einstellungen erstellt") # Auch bei Fallback die Nodes laden self._load_nodes_to_tree() except Exception as fallback_error: logger.error(f"Fehler beim Erstellen der Fallback-Einstellungen: {fallback_error}") def _initialize_saxon_worker_pool(self): """Initialisiert den Saxon-Worker-Pool für schnelle Transformationen.""" try: # Shutdown vorherigen Pool falls vorhanden self._shutdown_saxon_worker_pool() if not self.project: logger.warning("Kein Projekt geladen, Saxon-Worker-Pool nicht initialisiert") return # Hole Tool-Konfigurationen java_vm = next((vm for vm in app_settings.java_vms if vm.id == self.project.java_vm_id), None) saxon_jar = next((jar for jar in app_settings.saxon_jars if jar.id == self.project.saxon_jar_id), None) if not java_vm or not saxon_jar: logger.warning("Java VM oder Saxon JAR nicht gefunden, Pool nicht initialisiert") return # Erstelle Worker-Pool num_workers = app_settings.max_workers log_dir = self.project.project_dir / "temp" pool = SaxonWorkerPool( num_workers=num_workers, java_vm_path=java_vm.path_to_binary_file, saxon_jar_path=saxon_jar.path_to_jar_file, classpath_cache=TransformationJob._classpath_cache, log_dir=log_dir, ) # Setze globalen Pool set_saxon_worker_pool(pool) logger.info( f"Saxon-Worker-Pool initialisiert: {num_workers} Worker " f"(erwartet: {num_workers}x schneller für Saxon-Transformationen)" ) except Exception as e: logger.error(f"Fehler beim Initialisieren des Saxon-Worker-Pools: {e}") # Kein Pool ist OK - Fallback auf subprocess def _shutdown_saxon_worker_pool(self): """Beendet den Saxon-Worker-Pool sauber.""" try: # Importiere transform um Zugriff auf globalen Pool zu haben import transform if transform._saxon_worker_pool: logger.info("Beende Saxon-Worker-Pool...") transform._saxon_worker_pool.shutdown() set_saxon_worker_pool(None) logger.info("Saxon-Worker-Pool beendet") except Exception as e: logger.error(f"Fehler beim Beenden des Saxon-Worker-Pools: {e}") def change_theme(self, theme_name): """ Wechselt das Theme der Anwendung. Args: theme_name: Name des zu verwendenden Themes """ logger.info(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) logger.info(f"Theme erfolgreich gewechselt zu: {theme_name}") app_settings.theme = theme_name app_settings.save() else: logger.error(f"Fehler: Theme '{theme_name}' konnte nicht erstellt werden") except Exception as e: logger.error(f"Fehler beim Wechseln des Themes: {e}") 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) """ logger.debug(f"Rendere Seite {page_num + 1} von {pdf_filename}") if pdf_filename not in self.pdf_documents: logger.warning(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) logger.debug(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) logger.debug(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) logger.debug(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) logger.debug(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 logger.debug(f"Performance: Seite {page_num + 1} gerendert in {render_time:.3f}s") except Exception as e: logger.error(f"Fehler beim Rendern der Seite {page_num + 1}: {e}", exc_info=True) 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: logger.warning("Keine gerenderten Pixmaps verfügbar") return if self.fullsize_label is None: logger.warning("Fullsize-Label ist nicht verfügbar") return try: # 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) except RuntimeError as e: # C++-Objekt wurde bereits gelöscht logger.warning(f"Fullsize-Label wurde bereits gelöscht: {e}") self.fullsize_label = None 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 = abs(alpha_value) / 100 diff_opacity = 1.0 - abs(alpha_value) / 100.0 new_opacity = 0.0 else: 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) # Button "Accept Changes" verbinden self.ui.accept_changes.clicked.connect(self._on_accept_changes_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) # Verbinde Selection-Changed-Signal für automatisches Laden von Diff-PDFs self.ui.treeWidget.itemSelectionChanged.connect(self._on_tree_selection_changed) logger.debug("Kontextmenü und Selection-Handler für TreeWidget eingerichtet") def _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) logger.debug("TreeWidget Styling für größeren vertikalen Abstand angewendet") except Exception as e: logger.error(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 _on_tree_selection_changed(self): """ Handler für Änderungen der Tree-Selektion. Lädt automatisch Diff-PDFs wenn ein XML-Knoten mit Diff-PDF ausgewählt wird. Leert den Viewer wenn ein Knoten ohne Diff-PDF ausgewählt wird. """ try: logger.debug("Tree-Selektion geändert") # Hole aktuell selektierte Items selected_items = self.ui.treeWidget.selectedItems() if not selected_items or not self.project: # Keine Selektion oder kein Projekt - Viewer leeren logger.debug( f"Keine Selektion oder kein Projekt: selected_items={len(selected_items) if selected_items else 0}, project={self.project is not None}" ) if self.pdf_documents: self._clear_pdf_viewer() return # Erstes selektiertes Item verwenden item = selected_items[0] # Prüfe ob es ein XML-Item ist node_type = self._get_node_type_from_item(item) logger.debug(f"Selektierter Node-Typ: {node_type}") if node_type == "XmlFile": # Hole XmlFile-Objekt und XSL-ID aus UserRole xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole) xsl_id_str = item.data(1, Qt.ItemDataRole.UserRole) logger.debug(f"XML-File-Daten: xml_file_obj={xml_file_obj}, xsl_id_str={xsl_id_str}") if xml_file_obj and xsl_id_str: # Extrahiere Pfad aus XmlFile-Objekt xml_file_path = xml_file_obj.xml # Prüfe ob Diff-PDF existiert xml_stem = xml_file_path.stem pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf" diff_pdf_path = self.project.project_dir / "diff" / pdf_basename logger.debug(f"Prüfe Diff-PDF: {diff_pdf_path}, existiert={diff_pdf_path.exists()}") if diff_pdf_path.exists(): # Diff-PDF vorhanden - automatisch laden logger.info(f"XML-Knoten mit Diff-PDF ausgewählt: {pdf_basename}, lade automatisch") self._load_pdf_for_comparison(xml_file_path, xsl_id_str) else: # Kein Diff-PDF - Viewer leeren falls noch ein PDF geladen ist if self.pdf_documents: logger.debug("XML-Knoten ohne Diff-PDF ausgewählt, leere Viewer") self._clear_pdf_viewer() else: logger.debug("XML-File-Daten fehlen (xml_file_obj oder xsl_id_str ist None)") else: # Kein XML-Item - Viewer leeren falls noch ein PDF geladen ist if self.pdf_documents: logger.debug(f"Nicht-XML-Knoten ausgewählt ({node_type}), leere Viewer") self._clear_pdf_viewer() except Exception as e: logger.error(f"Fehler beim Verarbeiten der Tree-Selektion: {e}", exc_info=True) def _get_node_type_from_item(self, item): """ Bestimmt den Node-Typ basierend auf dem TreeWidgetItem. Args: item: Das TreeWidgetItem Returns: str: Der Node-Typ ('TreeNode', 'XslFile', 'XmlFile' oder 'Unknown') """ try: # Prüfe ob das Item ein Parent hat (dann ist es ein Child-Item) parent_item = item.parent() if parent_item: # Child-Item - prüfe ob es ein XML-File ist text = item.text(0) if text.startswith("XML:"): return "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: logger.error(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: logger.error(f"Fehler beim Bestimmen des Node-Typs aus Daten: {e}") return "Unknown" def _find_item_by_node(self, node_obj): """ Findet ein TreeWidgetItem basierend auf einem Node-Objekt. Args: node_obj: Das Node-Objekt (TreeNode, XslFile oder XmlFile) Returns: QTreeWidgetItem oder None wenn nicht gefunden """ def search_recursive(item): """Rekursive Suche durch TreeWidget.""" # Prüfe aktuelles Item item_node = item.data(0, Qt.ItemDataRole.UserRole) if item_node is node_obj: return item # Durchsuche Kinder for i in range(item.childCount()): child = item.child(i) result = search_recursive(child) if result: return result return None # Durchsuche alle Root-Items for i in range(self.ui.treeWidget.topLevelItemCount()): root_item = self.ui.treeWidget.topLevelItem(i) result = search_recursive(root_item) if result: return result return None def _find_node_by_id(self, nodes, target_id): """ Sucht rekursiv nach einem Node mit der angegebenen ID. Args: nodes: Liste der Nodes zum Durchsuchen target_id: Die zu suchende ID Returns: TreeNode|XslFile|None: Der gefundene Node oder None """ for node in nodes: if node.id == target_id: return node # Rekursiv in Knotenn suchen (nur bei TreeNode) if isinstance(node, TreeNode) and node.children: found = self._find_node_by_id(node.children, target_id) if found: return found return None def _create_context_menu_for_type(self, node_type, item): """ Erstellt das Kontextmenü für den angegebenen Node-Typ. Args: node_type: Der Typ des Nodes ('TreeNode', 'XslFile', 'XmlFile') item: Das TreeWidgetItem Returns: QMenu: Das erstellte Kontextmenü oder None """ try: menu = QMenu(self) if node_type == "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() # Transformations-Aktionen (nur aktiv wenn XML-Dateien vorhanden) tree_node_obj = item.data(0, Qt.ItemDataRole.UserRole) if item else None has_xml_files = bool(tree_node_obj and self._has_xml_files_recursive(tree_node_obj)) action_transform = QAction("Alle XML-Dateien transformieren", self) action_transform.setIcon(QIcon(QIcon.fromTheme("system-run"))) action_transform.triggered.connect(lambda: self._transform_tree_node(item)) action_transform.setEnabled(has_xml_files) menu.addAction(action_transform) action_transform_force = QAction("Alle XML-Dateien neu transformieren (force)", self) action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh"))) action_transform_force.triggered.connect(lambda: self._transform_tree_node(item, force=True)) action_transform_force.setEnabled(has_xml_files) menu.addAction(action_transform_force) menu.addSeparator() # Aktion "Alle Änderungen übernehmen" (nur aktiv wenn Diff-PDFs vorhanden) diff_pdfs = self._collect_all_diff_pdfs_under_node(tree_node_obj, item) if tree_node_obj else [] has_diff_pdfs = len(diff_pdfs) > 0 action_accept_all = QAction("Alle Änderungen übernehmen", self) action_accept_all.setIcon(QIcon(QIcon.fromTheme("emblem-default"))) action_accept_all.triggered.connect(lambda: self._accept_all_changes_under_node(item)) action_accept_all.setEnabled(has_diff_pdfs) menu.addAction(action_accept_all) menu.addSeparator() action_edit = QAction("Bearbeiten", self) action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties))) action_edit.triggered.connect(lambda: self._edit_tree_node(item)) menu.addAction(action_edit) action_delete = QAction("Löschen", self) action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete))) action_delete.triggered.connect(lambda: self._delete_tree_node(item)) menu.addAction(action_delete) elif node_type == "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 (nur aktiv wenn XML-Dateien vorhanden) xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole) if item else None has_xml_files = bool(xsl_file_obj and xsl_file_obj.xmls) action_transform = QAction("Alle XML-Dateien transformieren", self) action_transform.setIcon(QIcon(QIcon.fromTheme("system-run"))) action_transform.triggered.connect(lambda: self._transform_xsl_file(item)) action_transform.setEnabled(has_xml_files) menu.addAction(action_transform) action_transform_force = QAction("Alle XML-Dateien neu transformieren (force)", self) action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh"))) action_transform_force.triggered.connect(lambda: self._transform_xsl_file(item, force=True)) action_transform_force.setEnabled(has_xml_files) menu.addAction(action_transform_force) menu.addSeparator() # Aktion "Alle Änderungen übernehmen" (nur aktiv wenn Diff-PDFs vorhanden) diff_pdfs = self._collect_all_diff_pdfs_under_node(xsl_file_obj, item) if xsl_file_obj else [] has_diff_pdfs = len(diff_pdfs) > 0 action_accept_all = QAction("Alle Änderungen übernehmen", self) action_accept_all.setIcon(QIcon(QIcon.fromTheme("emblem-default"))) action_accept_all.triggered.connect(lambda: self._accept_all_changes_under_node(item)) action_accept_all.setEnabled(has_diff_pdfs) menu.addAction(action_accept_all) menu.addSeparator() action_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: logger.error(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) """ logger.debug(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 logger.debug(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 logger.info("Einstellungen wurden gespeichert") except Exception as e: logger.error(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, fop_config_dir=Path(project_data["fop_config_dir"]) if project_data.get("fop_config_dir") else None, ) # 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() logger.info(f"Neues PDF-Projekt '{project_data['name']}' wurde erstellt und gespeichert") logger.debug(f"Projekt-ID: {new_id}") logger.debug(f"Projekt-Ordner: {project_data['project_dir']}") # Aktualisiere das Projekte-Menü self._setup_projects_menu() except Exception as e: logger.error(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) logger.debug(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) logger.info(f"project.yaml mit Standard-Einstellungen erstellt: {project_yaml_path}") else: logger.debug(f"project.yaml existiert bereits: {project_yaml_path}") except Exception as e: logger.error(f"Fehler beim Erstellen der Projekt-Struktur: {e}") raise def on_button_clicked(self): """Wird ausgeführt, wenn der Button geklickt wird.""" logger.debug("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"] logger.debug(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 logger.debug(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. """ logger.info("Lade Nodes in TreeWidget...") try: # TreeWidget leeren self.ui.treeWidget.clear() # Lösche XML-Item-Map self.xml_item_map.clear() # Prüfe ob pdf_project existiert und Nodes hat if not hasattr(self, "pdf_project") or not self.pdf_project: logger.warning("Keine Projekt-Einstellungen verfügbar") return if not self.pdf_project.nodes: logger.warning("Keine Nodes in den Projekt-Einstellungen gefunden") return # Sortiere Root-Nodes alphabetisch nach ID sorted_nodes = sorted(self.pdf_project.nodes, key=lambda node: node.id) # Lade alle Root-Nodes (sortiert) for node in sorted_nodes: tree_item = self._create_tree_item_from_node(node) self.ui.treeWidget.addTopLevelItem(tree_item) logger.info(f"{len(self.pdf_project.nodes)} Root-Nodes in TreeWidget geladen (alphabetisch sortiert)") # Aktualisiere Diff-PDF-Anzahl und Icons nach dem Laden self._update_all_diff_pdf_counts() self._update_diff_icons_for_existing_pdfs() except Exception as e: logger.error(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) # Setze Diff-PDF-Anzahl in Spalte 2 (wird später aktualisiert) diff_count = self._count_diff_pdfs_under_node(node, item) if diff_count > 0: item.setText(2, str(diff_count)) 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) # Setze Diff-PDF-Anzahl in Spalte 2 (wird später aktualisiert) diff_count = self._count_diff_pdfs_under_node(node, item) if diff_count > 0: item.setText(2, str(diff_count)) # 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}") # Speichere XSL-ID in Spalte 1, UserRole für einfachen Zugriff xsl_id_str = "_".join(str(x) for x in node.id) xml_item.setData(1, Qt.ItemDataRole.UserRole, xsl_id_str) item.addChild(xml_item) # Speichere XML-Item für spätere Widget-Updates (Progress Bar, Icon) # Key: "xml_path|xsl_id" um mehrfache Verwendung derselben XML zu unterstützen xml_path_str = str(xml.xml) xsl_id_str = "_".join(str(x) for x in node.id) map_key = f"{xml_path_str}|{xsl_id_str}" self.xml_item_map[map_key] = xml_item logger.debug(f"XML-Item zur Map hinzugefügt: '{map_key}'") return item except Exception as e: logger.error(f"Fehler beim Erstellen des Tree-Items: {e}") # Fallback: Erstelle einfaches Item fallback_item = QTreeWidgetItem() fallback_item.setText(0, "Fehler beim Laden") fallback_item.setText(1, str(e)) return fallback_item def _create_centered_progress_bar(self) -> tuple[QWidget, QProgressBar]: """ Erstellt eine linksbündige Progress Bar in einem Container-Widget. Returns: tuple: (container_widget, progress_bar) """ # Container-Widget erstellen container = QWidget() layout = QHBoxLayout(container) layout.setContentsMargins(0, 0, 0, 0) layout.setAlignment(Qt.AlignmentFlag.AlignLeft) # Progress Bar erstellen (indeterminate mode für pulsierenden Effekt) progress_bar = QProgressBar() progress_bar.setMinimum(0) progress_bar.setMaximum(0) # Pulsierend progress_bar.setMaximumWidth(80) # Kompakte Breite progress_bar.setMaximumHeight(16) # Kompakte Höhe progress_bar.setTextVisible(False) layout.addWidget(progress_bar) return container, progress_bar def _create_centered_diff_icon(self, xml_file_path: Path, xsl_id_str: str) -> QWidget: """ Erstellt ein linksbündiges, nicht-klickbares Icon für Diff-PDF. Args: xml_file_path: Pfad zur XML-Datei (relativ) xsl_id_str: XSL-ID als String (z.B. "2002_1_128") Returns: QWidget: Container mit Icon """ # Container-Widget container = QWidget() layout = QHBoxLayout(container) layout.setContentsMargins(0, 0, 0, 0) layout.setAlignment(Qt.AlignmentFlag.AlignLeft) # Icon-Label icon_label = QLabel() # Icon für Diff-View mit Fallbacks icon = QIcon.fromTheme("view-split-left-right") if icon.isNull(): icon = QIcon.fromTheme("vcs-diff") if icon.isNull(): icon = QIcon.fromTheme("system-search") # Letzter Fallback icon_label.setPixmap(icon.pixmap(16, 16)) icon_label.setToolTip("Diff-PDF vorhanden (wird automatisch geladen bei Selektion)") layout.addWidget(icon_label) return container def _load_pdf_for_comparison(self, xml_file_path: Path, xsl_id_str: str): """ Lädt die PDFs (diff, ref, new) einer Transformation in den Vergleichs-Viewer. Args: xml_file_path: Pfad zur XML-Datei (relativ) xsl_id_str: XSL-ID als String (z.B. "2002_1_128") """ try: if not self.project: QMessageBox.warning(self, "Fehler", "Kein Projekt geöffnet") return # Ermittle PDF-Dateinamen basierend auf XML und XSL-ID xml_stem = xml_file_path.stem pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf" # Pfade zu den drei PDFs diff_dir = self.project.project_dir / "diff" ref_dir = self.project.project_dir / "ref" new_dir = self.project.project_dir / "new" diff_pdf_path = diff_dir / pdf_basename ref_pdf_path = ref_dir / pdf_basename new_pdf_path = new_dir / pdf_basename # Prüfe ob PDFs existieren if not diff_pdf_path.exists(): QMessageBox.information(self, "Keine Diff-PDF", f"Diff-PDF nicht gefunden:\n{pdf_basename}") return if not ref_pdf_path.exists() or not new_pdf_path.exists(): QMessageBox.warning( self, "Fehlende PDFs", f"Ref-PDF oder New-PDF nicht gefunden:\n{pdf_basename}\n\nNur Diff-PDF vorhanden.", ) return logger.info(f"Lade PDFs für Vergleich: {pdf_basename}") # 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 self.fullsize_label = None # Label wurde durch _clear_layout gelöscht # Alle drei PDF-Dateien öffnen mit QtPdf diff_doc = QPdfDocument() ref_doc = QPdfDocument() new_doc = QPdfDocument() # PDF-Dateien laden diff_doc.load(str(diff_pdf_path)) ref_doc.load(str(ref_pdf_path)) new_doc.load(str(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 ): QMessageBox.critical(self, "Fehler", f"Fehler beim Laden der PDFs:\n{pdf_basename}") return # PDF-Dokumente speichern self.pdf_documents[pdf_basename] = {"diff": diff_doc, "ref": ref_doc, "new": new_doc} logger.info(f"PDFs geladen: {pdf_basename}") logger.info(f" diff: {diff_doc.pageCount()} Seiten") logger.info(f" ref: {ref_doc.pageCount()} Seiten") logger.info(f" new: {new_doc.pageCount()} Seiten") # Nehme die Seitenzahl der diff-PDF als Basis max_pages = diff_doc.pageCount() # Erstelle Thumbnails für alle Seiten 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 scale_factor = 200.0 / page_size.width() # 200 Pixel Breite # 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_basename}_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_basename, "page_num": page_num} # Click-Event für das Thumbnail einrichten thumbnail.mousePressEvent = lambda event, t=thumbnail: self.on_thumbnail_clicked(event, t) # Erstelle das Vollbild-Label für die rechte Spalte (falls noch nicht vorhanden) 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 ) # Setze die aktuelle PDF self.current_pdf = pdf_basename # Speichere Diff-PDF-Informationen für Accept Changes self.current_diff_xml_path = xml_file_path self.current_diff_xsl_id = xsl_id_str # Aktiviere Accept-Changes-Button self.ui.accept_changes.setEnabled(True) # Zeige die erste Seite initial an self.render_and_display_page(pdf_basename, 0) logger.info(f"PDF-Vergleich geladen: {pdf_basename}") except Exception as e: logger.error(f"Fehler beim Laden der PDFs für Vergleich: {e}") QMessageBox.critical(self, "Fehler", f"Konnte PDFs nicht laden:\n{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, xsl_id_str) 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.""" logger.debug(f"Unterknoten zu TreeNode hinzufügen: {parent_item.text(0)}") # TODO: Dialog zum Eingeben der Node-Daten öffnen def _add_xsl_file_to_node(self, parent_item): """Fügt eine XSL-Datei zu einem TreeNode hinzu.""" logger.debug(f"XSL-Datei zu TreeNode hinzufügen: {parent_item.text(0)}") # TODO: Dialog zum Auswählen der XSL-Datei öffnen def _edit_tree_node(self, item): """ Bearbeitet einen TreeNode. Args: item: Das TreeWidgetItem des TreeNode """ logger.debug(f"TreeNode bearbeiten: {item.text(0)}") try: # Hole das Node-Objekt aus dem TreeWidgetItem node = item.data(0, Qt.ItemDataRole.UserRole) if not node or not isinstance(node, TreeNode): QMessageBox.warning(self, "Warnung", "Kein gültiger TreeNode gefunden.") return # Prüfe ob Projekt verfügbar ist if not self.pdf_project: QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen verfügbar.") return # Sammle Eltern-Parameter parent_params = self._collect_parent_params(item) # Erstelle und zeige den Dialog dialog = TreeNodeEditDialog(self, node, parent_params) if dialog.exec() == TreeNodeEditDialog.DialogCode.Accepted: # Hole die bearbeiteten Daten data = dialog.get_data() if data: # Aktualisiere den Node node.bez = data["bez"] node.xslt_params = data["xslt_params"] logger.info(f"TreeNode '{node.bez}' wurde aktualisiert") logger.debug(f"XSLT-Parameter: {node.xslt_params}") # Speichere die Änderungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() # Wenn Force-Transformation gewünscht, führe sie aus if data.get("force_transform", False): # Finde das neue Item nach dem Neuladen new_item = self._find_item_by_node(node) if new_item: logger.info(f"Starte Force-Transformation für TreeNode '{node.bez}'") self._transform_tree_node(new_item, force=True) else: logger.warning(f"Konnte Item für TreeNode '{node.bez}' nicht finden") # QMessageBox.information(self, "Erfolg", "TreeNode wurde erfolgreich aktualisiert.") except Exception as e: error_msg = f"Fehler beim Bearbeiten des TreeNode: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _delete_tree_node(self, item): """Löscht einen TreeNode.""" logger.debug(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 """ logger.debug(f"XML-Datei zu XslFile hinzufügen: {parent_item.text(0)}") try: # Prüfe ob ein Projekt geladen ist if not hasattr(self, "project") or not self.project: QMessageBox.warning(self, "Warnung", "Kein Projekt geladen. Bitte öffnen Sie zuerst ein Projekt.") return if not hasattr(self, "pdf_project") or not self.pdf_project: QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen geladen.") return # Hole das XslFile-Node-Objekt direkt aus dem TreeWidgetItem xsl_node = parent_item.data(0, Qt.ItemDataRole.UserRole) if not xsl_node or not isinstance(xsl_node, XslFile): QMessageBox.warning(self, "Warnung", "Keine gültige XSL-Datei-Node gefunden.") return # Öffne Datei-Dialog zum Auswählen der XML-Datei xml_file_path, _ = QFileDialog.getOpenFileName( self, "XML-Datei auswählen", "", "XML-Dateien (*.xml);;Alle Dateien (*)" ) if not xml_file_path: # Benutzer hat abgebrochen return xml_file_path = Path(xml_file_path) # Prüfe ob die Datei existiert if not xml_file_path.exists(): QMessageBox.critical(self, "Fehler", f"Die ausgewählte XML-Datei existiert nicht:\n{xml_file_path}") return # Erstelle xml-Ordner im Projekt-Verzeichnis falls er nicht existiert xml_dir = Path(self.project.project_dir) / "xml" xml_dir.mkdir(parents=True, exist_ok=True) # Bestimme den Ziel-Pfad in xml-Ordner target_xml_path = xml_dir / xml_file_path.name # Prüfe ob eine Datei mit gleichem Namen bereits existiert if target_xml_path.exists(): reply = QMessageBox.question( self, "Datei existiert bereits", f"Eine XML-Datei mit dem Namen '{xml_file_path.name}' existiert bereits im xml-Ordner.\n\n" "Möchten Sie sie überschreiben?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return # Kopiere die XML-Datei in den xml-Ordner shutil.copy2(xml_file_path, target_xml_path) logger.info(f"XML-Datei kopiert: {xml_file_path} -> {target_xml_path}") # Erstelle relatives Path zur XML-Datei (relativ zum xml-Ordner) relative_xml_path = Path("xml") / xml_file_path.name # Prüfe ob diese XML-Datei bereits in der XslFile-Node vorhanden ist existing_xml = None for xml_file in xsl_node.xmls: if xml_file.xml == relative_xml_path: existing_xml = xml_file break if existing_xml: QMessageBox.information( self, "XML-Datei bereits vorhanden", f"Die XML-Datei '{xml_file_path.name}' ist bereits in dieser XSL-Datei enthalten.", ) return # Erstelle neues XmlFile-Objekt und füge es zur XslFile-Node hinzu new_xml_file = XmlFile(xml=relative_xml_path) xsl_node.xmls.append(new_xml_file) logger.info(f"XML-Datei '{xml_file_path.name}' zu XslFile-Node '{xsl_node.bez}' hinzugefügt") # Berechne Hash für die neue XML-Datei self._calculate_hash_for_xml_file(new_xml_file) # Speichere die aktualisierten Projekt-Einstellungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() # 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)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _edit_xsl_file(self, item): """ Bearbeitet eine XSL-Datei. Args: item: Das TreeWidgetItem des XslFile """ logger.debug(f"XslFile bearbeiten: {item.text(0)}") try: # Hole das Node-Objekt aus dem TreeWidgetItem node = item.data(0, Qt.ItemDataRole.UserRole) if not node or not isinstance(node, XslFile): QMessageBox.warning(self, "Warnung", "Keine gültige XSL-Datei gefunden.") return # Sammle Eltern-Parameter parent_params = self._collect_parent_params(item) # Erstelle und zeige den Dialog dialog = XslFileEditDialog(self, node, parent_params) if dialog.exec() == XslFileEditDialog.DialogCode.Accepted: # Hole die bearbeiteten Daten data = dialog.get_data() if data: # Aktualisiere den Node node.bez = data["bez"] node.xslt_params = data["xslt_params"] logger.info(f"XslFile '{node.bez}' wurde aktualisiert") logger.debug(f"XSLT-Parameter: {node.xslt_params}") # Speichere die Änderungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() # Wenn Force-Transformation gewünscht, führe sie aus if data.get("force_transform", False): # Finde das neue Item nach dem Neuladen new_item = self._find_item_by_node(node) if new_item: logger.info(f"Starte Force-Transformation für XslFile '{node.bez}'") self._transform_xsl_file(new_item, force=True) else: logger.warning(f"Konnte Item für XslFile '{node.bez}' nicht finden") # QMessageBox.information(self, "Erfolg", "XSL-Datei wurde erfolgreich aktualisiert.") except Exception as e: error_msg = f"Fehler beim Bearbeiten der XSL-Datei: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _delete_xsl_file(self, item): """Löscht eine XSL-Datei.""" logger.debug(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.""" logger.debug(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 """ logger.debug(f"XmlFile löschen: {item.text(0)}") try: # Prüfe ob ein Projekt geladen ist if not hasattr(self, "project") or not self.project: QMessageBox.warning(self, "Warnung", "Kein Projekt geladen. Bitte öffnen Sie zuerst ein Projekt.") return if not hasattr(self, "pdf_project") or not self.pdf_project: QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen geladen.") return # Hole das XmlFile-Objekt aus dem TreeWidgetItem xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole) if not xml_file_obj or not isinstance(xml_file_obj, XmlFile): QMessageBox.warning(self, "Warnung", "Keine gültige XML-Datei gefunden.") return # Hole das Eltern-Item (sollte ein XslFile sein) parent_item = item.parent() if not parent_item: QMessageBox.warning(self, "Warnung", "Eltern-XSL-Datei nicht gefunden.") return # Hole das XslFile-Objekt aus dem Eltern-Item xsl_file_obj = parent_item.data(0, Qt.ItemDataRole.UserRole) if not xsl_file_obj or not isinstance(xsl_file_obj, XslFile): QMessageBox.warning(self, "Warnung", "Keine gültige Eltern-XSL-Datei gefunden.") return # Bestätigungsdialog anzeigen xml_filename = xml_file_obj.xml.name reply = QMessageBox.question( self, "XML-Datei löschen", f"Möchten Sie die XML-Datei '{xml_filename}' aus der XSL-Datei '{xsl_file_obj.bez}' entfernen?\n\n" "Die XML-Datei wird nur aus der Zuordnung entfernt, nicht physisch gelöscht.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: logger.debug("Löschung abgebrochen") return # Entferne die XML-Datei aus der XslFile-Node xml_files_before = len(xsl_file_obj.xmls) xsl_file_obj.xmls = [xml for xml in xsl_file_obj.xmls if xml.xml != xml_file_obj.xml] xml_files_after = len(xsl_file_obj.xmls) if xml_files_before == xml_files_after: QMessageBox.warning(self, "Warnung", "XML-Datei konnte nicht aus der XSL-Datei entfernt werden.") return logger.info(f"XML-Datei '{xml_filename}' aus XSL-Datei '{xsl_file_obj.bez}' entfernt") # Frage ob die physische Datei auch gelöscht werden soll xml_file_path = Path(self.project.project_dir) / xml_file_obj.xml if xml_file_path.exists(): # Prüfe ob die XML-Datei noch in anderen XSL-Dateien verwendet wird is_used_elsewhere = self._is_xml_file_used_elsewhere(xml_file_obj.xml, xsl_file_obj) if not is_used_elsewhere: delete_reply = QMessageBox.question( self, "Physische Datei löschen", f"Die XML-Datei '{xml_filename}' wird in keiner anderen XSL-Datei verwendet.\n\n" "Möchten Sie auch die physische Datei aus dem xml-Ordner löschen?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if delete_reply == QMessageBox.StandardButton.Yes: try: xml_file_path.unlink() logger.info(f"Physische XML-Datei gelöscht: {xml_file_path}") except Exception as e: QMessageBox.warning(self, "Warnung", f"Fehler beim Löschen der physischen Datei:\n{str(e)}") else: logger.info( f"XML-Datei '{xml_filename}' wird noch in anderen XSL-Dateien verwendet - physische Datei nicht gelöscht" ) # Speichere die aktualisierten Projekt-Einstellungen self._save_project_settings() # Aktualisiere das TreeWidget self._load_nodes_to_tree() logger.info(f"XML-Datei '{xml_filename}' erfolgreich entfernt") except Exception as e: error_msg = f"Fehler beim Löschen der XML-Datei: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _is_xml_file_used_elsewhere(self, xml_path, exclude_xsl_file): """ Prüft ob eine XML-Datei noch in anderen XSL-Dateien verwendet wird. Args: xml_path: Pfad zur XML-Datei (relativ) exclude_xsl_file: XSL-Datei die ausgeschlossen werden soll Returns: bool: True wenn die XML-Datei noch anderswo verwendet wird """ try: # Prüfe ob pdf_project und nodes existieren if not self.pdf_project or not self.pdf_project.nodes: return False # Keine Nodes vorhanden, also nicht verwendet return self._check_xml_usage_recursive(self.pdf_project.nodes, xml_path, exclude_xsl_file) except Exception as e: logger.error(f"Fehler beim Prüfen der XML-Datei-Verwendung: {e}") return True # Im Zweifelsfall annehmen, dass sie verwendet wird def _check_xml_usage_recursive(self, nodes, xml_path, exclude_xsl_file): """ Prüft rekursiv ob eine XML-Datei in den Nodes verwendet wird. Args: nodes: Liste der zu prüfenden Nodes xml_path: Pfad zur XML-Datei (relativ) exclude_xsl_file: XSL-Datei die ausgeschlossen werden soll Returns: bool: True wenn die XML-Datei gefunden wird """ for node in nodes: if isinstance(node, XslFile) and node != exclude_xsl_file: # Prüfe ob diese XSL-Datei die XML-Datei verwendet for xml_file in node.xmls: if xml_file.xml == xml_path: return True elif isinstance(node, TreeNode) and node.children: # Rekursiv in Knoten suchen if self._check_xml_usage_recursive(node.children, xml_path, exclude_xsl_file): return True return False # Kontextmenü-Aktionen für Root-Elemente (Unbekannter Typ) def _add_root_tree_node(self): """Fügt einen neuen TreeNode als Root-Element hinzu.""" logger.debug("Neuen TreeNode als Root-Element hinzufügen") # TODO: Dialog zum Eingeben der TreeNode-Daten öffnen def 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. """ logger.debug("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: logger.error(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() logger.debug(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}" ) logger.info(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)}" logger.error(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 logger.debug(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 logger.debug(f"Performance: Node-Erstellung in {nodes_time:.3f}s") logger.info(f"Erstellt: {len(new_nodes)} Root-Nodes") return new_nodes except Exception as e: logger.error(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: logger.info("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) logger.debug(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) logger.info(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 logger.info(f"Alle {len(new_nodes)} Root-Nodes hinzugefügt (keine vorhandenen Nodes)") logger.info("Merge abgeschlossen") except Exception as e: logger.error(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: logger.info( 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: logger.info( 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) logger.info(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 von der Wurzel bis zum angegebenen Item. Parameter werden von oben nach unten gesammelt, wobei tiefere Ebenen höhere Priorität haben. Args: item: Das TreeWidgetItem (kann TreeNode oder XslFile sein) Returns: dict: Dictionary mit allen gesammelten Parametern (tiefere Ebenen überschreiben höhere) """ parent_params = {} try: # Sammle alle Eltern-Items in einer Liste (von unten nach oben) parents = [] current_item = item.parent() while current_item: parents.append(current_item) current_item = current_item.parent() # Kehre Liste um, sodass wir von Wurzel zu Kind iterieren parents.reverse() # Sammle Parameter von Wurzel zu Kind (Kind überschreibt Eltern) for parent_item in parents: parent_node = parent_item.data(0, Qt.ItemDataRole.UserRole) if parent_node and hasattr(parent_node, "xslt_params") and parent_node.xslt_params: # Update überschreibt vorherige Werte (höhere Priorität für tiefere Ebenen) parent_params.update(parent_node.xslt_params) logger.debug(f"Gesammelte Eltern-Parameter: {parent_params}") return parent_params except Exception as e: logger.error(f"Fehler beim Sammeln der Eltern-Parameter: {e}") return {} def _save_project_settings(self): """ Speichert die aktualisierten Projekt-Einstellungen. Args: current_project: Das aktuelle Projekt """ try: # Prüfe ob pdf_project und project existieren if not self.pdf_project: logger.warning("Keine Projekt-Einstellungen zum Speichern verfügbar") return if not self.project or not self.project.project_dir: logger.warning("Kein Projekt-Verzeichnis zum Speichern verfügbar") return start_time = time.time() # Speichere in project.yaml im Projekt-Verzeichnis self.pdf_project.writeSettings(project_dir=self.project.project_dir) dump_time = time.time() - start_time logger.debug(f"Performance: Projekt-Einstellungen gespeichert in {dump_time:.3f}s") except Exception as e: logger.error(f"Fehler beim Speichern der Projekt-Einstellungen: {e}") raise def _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 logger.debug("Drag&Drop für TreeWidget aktiviert") except Exception as e: logger.error(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() logger.debug(f"Drag-Enter akzeptiert: {len(xml_files)} XML-Dateien") else: event.ignore() logger.debug("Drag-Enter ignoriert: Keine XML-Dateien") else: event.ignore() logger.debug("Drag-Enter ignoriert: Keine URLs") except Exception as e: logger.error(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: logger.error(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 logger.info(f"Drop-Event: {len(xml_files)} XML-Dateien erkannt") # Verarbeite alle XML-Dateien mit optionalem "Alle zuordnen" Feature self._handle_multiple_xml_files_drop(xml_files) event.acceptProposedAction() except Exception as e: error_msg = f"Fehler beim Verarbeiten des Drop-Events: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) event.ignore() def _handle_multiple_xml_files_drop(self, xml_files: list): """ Verarbeitet mehrere XML-Dateien asynchron per Drag&Drop. Zeigt einen Dialog zur Auswahl der XSL-Knoten und startet dann die Batch-Verarbeitung im Hintergrund. Args: xml_files: Liste von Pfaden zu XML-Dateien """ if not xml_files: return try: # 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 # Zeige Dialog für die erste Datei dialog = XmlToXslAssignDialog(parent=self, xml_file_path=xml_files[0], project_nodes=self.pdf_project.nodes) if dialog.exec() != XmlToXslAssignDialog.DialogCode.Accepted: logger.debug("Dialog abgebrochen - keine Dateien verarbeitet") return # Hole die ausgewählten XSL-Knoten selected_xsl_nodes = dialog.get_selected_xsl_nodes() if not selected_xsl_nodes: logger.warning("Keine XSL-Knoten ausgewählt") return # Prüfe ob "Alle zuordnen" aktiviert wurde apply_to_all = dialog.is_apply_to_all() # Bestimme welche Dateien verarbeitet werden sollen files_to_process = xml_files if apply_to_all else [xml_files[0]] # Stoppe vorherigen Batch-Thread falls noch aktiv if self.batch_processing_thread and self.batch_processing_thread.isRunning(): self.batch_processing_thread.quit() self.batch_processing_thread.wait() # Zusätzliche Sicherheitsprüfung für project_dir if not self.project or not self.project.project_dir: QMessageBox.warning(self, "Fehler", "Projekt-Verzeichnis ist nicht verfügbar") return # Erstelle und starte neuen Batch-Verarbeitungs-Thread self.batch_processing_thread = XmlBatchProcessingThread( xml_files=files_to_process, selected_xsl_nodes=selected_xsl_nodes, project_dir=Path(self.project.project_dir), pdf_project=self.pdf_project, ) # Verbinde Signale self.batch_processing_thread.progress_update.connect(self._on_batch_progress_update) self.batch_processing_thread.processing_finished.connect(self._on_batch_processing_finished) # Zeige Progressbar self._show_batch_progress_bar(len(files_to_process)) # Starte Thread self.batch_processing_thread.start() logger.info( f"Batch-Verarbeitung von {len(files_to_process)} Datei(en) gestartet (apply_to_all={apply_to_all})" ) except Exception as e: error_msg = f"Fehler beim Starten der Batch-Verarbeitung: {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _show_batch_progress_bar(self, total_files: int): """ Zeigt einen Progressbar in der Statusbar für die Batch-Verarbeitung. Args: total_files: Gesamtanzahl der zu verarbeitenden Dateien """ if self.batch_progress_bar is None: self.batch_progress_bar = QProgressBar() self.batch_progress_bar.setMaximumHeight(20) self.batch_progress_bar.setMaximumWidth(300) self.batch_progress_bar.setMinimum(0) self.batch_progress_bar.setMaximum(total_files) self.batch_progress_bar.setValue(0) self.batch_progress_bar.setFormat("%v/%m Dateien") # Füge Progressbar zur Statusbar hinzu self.statusBar().addPermanentWidget(self.batch_progress_bar) self.batch_progress_bar.show() def _hide_batch_progress_bar(self): """Versteckt und entfernt den Progressbar aus der Statusbar.""" if self.batch_progress_bar: self.statusBar().removeWidget(self.batch_progress_bar) self.batch_progress_bar.hide() def _on_batch_progress_update(self, current: int, total: int, current_file: str): """ Wird aufgerufen wenn der Batch-Thread einen Fortschritt meldet. Args: current: Aktuelle Dateinummer total: Gesamtanzahl der Dateien current_file: Name der aktuellen Datei """ if self.batch_progress_bar: self.batch_progress_bar.setValue(current) self.statusBar().showMessage(f"Verarbeite: {current_file} ({current}/{total})") def _on_batch_processing_finished(self, stats: dict): """ Wird aufgerufen wenn die Batch-Verarbeitung abgeschlossen ist. Args: stats: Statistik-Dictionary mit Verarbeitungsergebnissen """ try: # Verstecke Progressbar self._hide_batch_progress_bar() # Speichere Projekt-Einstellungen if stats["processed"] > 0: self._save_project_settings() # Aktualisiere Tree self._load_nodes_to_tree() # Zeige Zusammenfassungsdialog self._show_drop_summary_dialog(stats) # Statusbar-Nachricht self.statusBar().showMessage( f"Batch-Verarbeitung abgeschlossen: {stats['processed']}/{stats['total']} Dateien", 5000 ) except Exception as e: logger.error(f"Fehler beim Abschließen der Batch-Verarbeitung: {e}") def _show_transformation_progress_bar(self, total_jobs: int): """ Zeigt einen Progressbar in der Statusbar für Transformationen. Args: total_jobs: Gesamtanzahl der Transformations-Jobs """ if self.transformation_progress_bar is None: self.transformation_progress_bar = QProgressBar() self.transformation_progress_bar.setMaximumHeight(20) self.transformation_progress_bar.setMaximumWidth(300) self.transformation_progress_bar.setMinimum(0) self.transformation_progress_bar.setMaximum(total_jobs) self.transformation_progress_bar.setValue(0) self.transformation_progress_bar.setFormat("%v/%m Jobs") # Füge Progressbar zur Statusbar hinzu self.statusBar().addPermanentWidget(self.transformation_progress_bar) self.transformation_progress_bar.show() def _hide_transformation_progress_bar(self): """Versteckt und entfernt den Transformation-Progressbar aus der Statusbar.""" if self.transformation_progress_bar: self.statusBar().removeWidget(self.transformation_progress_bar) self.transformation_progress_bar.hide() def _update_transformation_progress(self): """Aktualisiert den Transformation-Progressbar um einen Schritt.""" if self.transformation_progress_bar: current_value = self.transformation_progress_bar.value() self.transformation_progress_bar.setValue(current_value + 1) def _show_drop_summary_dialog(self, stats: dict): """ Zeigt einen Zusammenfassungsdialog über die verarbeiteten XML-Dateien. Args: stats: Statistik-Dictionary mit Verarbeitungsergebnissen """ # Erstelle Zusammenfassungstext summary_lines = [] summary_lines.append("Verarbeitung abgeschlossen:\n") summary_lines.append(f"📊 Gesamt: {stats['total']} Datei(en)") summary_lines.append(f"✓ Verarbeitet: {stats['processed']} Datei(en)") if stats["new_added"] > 0: summary_lines.append(f"➕ Neu hinzugefügt: {stats['new_added']} Datei(en)") if stats["existing_added"] > 0: summary_lines.append(f"🔗 Vorhandene zugeordnet: {stats['existing_added']} Datei(en)") if stats["already_assigned"] > 0: summary_lines.append(f"ℹ️ Bereits zugeordnet: {stats['already_assigned']} Datei(en)") if stats["cancelled"] > 0: summary_lines.append(f"🚫 Abgebrochen: {stats['cancelled']} Datei(en)") if stats["renamed_files"]: summary_lines.append("\n📝 Umbenannte Dateien:") for renamed in stats["renamed_files"]: summary_lines.append(f" • {renamed}") if stats["errors"] > 0: summary_lines.append(f"\n❌ Fehler: {stats['errors']}") for error_msg in stats["error_messages"][:5]: # Zeige max. 5 Fehler summary_lines.append(f" • {error_msg}") if len(stats["error_messages"]) > 5: summary_lines.append(f" ... und {len(stats['error_messages']) - 5} weitere Fehler") summary_text = "\n".join(summary_lines) # Wähle Icon basierend auf Erfolg if stats["errors"] > 0: QMessageBox.warning(self, "Verarbeitung mit Fehlern abgeschlossen", summary_text) elif stats["cancelled"] > 0: QMessageBox.information(self, "Verarbeitung abgebrochen", summary_text) else: QMessageBox.information(self, "Verarbeitung erfolgreich", summary_text) def _handle_xml_file_drop(self, xml_file_path: Path): """ Verarbeitet eine einzelne XML-Datei, die per Drag&Drop hinzugefügt wurde. DEPRECATED: Diese Methode wird durch _handle_multiple_xml_files_drop ersetzt. Args: xml_file_path: Pfad zur XML-Datei """ try: logger.debug(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: logger.warning("Keine XSL-Knoten ausgewählt") else: logger.debug("Dialog abgebrochen") except Exception as e: error_msg = f"Fehler beim Verarbeiten der XML-Datei '{xml_file_path}': {str(e)}" logger.error(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 Returns: dict: Statistiken über die Verarbeitung """ 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}") return 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") return 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) return {"status": "error", "error_msg": 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 Returns: dict: Statistiken mit 'status', 'added_count', 'existing_file' """ 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() return { "status": "existing_added", "added_count": added_count, "existing_file": existing_xml.xml.name, } else: return {"status": "already_assigned", "added_count": 0, "existing_file": existing_xml.xml.name} except Exception as e: error_msg = f"Fehler beim Zuordnen der vorhandenen XML-Datei: {str(e)}" logger.error(error_msg) return {"status": "error", "error_msg": 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 Returns: dict: Statistiken mit 'status', 'added_count', 'new_file', 'renamed_from' """ 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") return {"status": "error", "error_msg": "Kein Projekt-Verzeichnis verfügbar."} # 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 {"status": "cancelled", "added_count": 0} 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() return { "status": "new_added", "added_count": added_count, "new_file": target_xml_path.name, "renamed_from": xml_file_path.name if target_xml_path.name != xml_file_path.name else None, } else: return { "status": "already_assigned", "added_count": 0, "new_file": target_xml_path.name, } except Exception as e: error_msg = f"Fehler beim Verarbeiten der neuen XML-Datei: {str(e)}" logger.error(error_msg) return {"status": "error", "error_msg": 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 mit TreeWidgetItem-Kontext für Parameter-Sammlung job = self._create_transformation_job(xsl_file_obj, xml_file_obj, parent_item) 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: # Übergebe das XslFile-TreeWidgetItem für Parameter-Sammlung job = self._create_transformation_job(xsl_file_obj, xml_file_obj, item) 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 _count_diff_pdfs_under_node(self, node: TreeNode | XslFile, node_item: QTreeWidgetItem) -> int: """ Zählt die Anzahl der existierenden Diff-PDFs unter einem Knoten. Args: node: TreeNode oder XslFile Objekt node_item: Das TreeWidgetItem des Knotens Returns: int: Anzahl der existierenden Diff-PDF-Dateien """ count = 0 if isinstance(node, XslFile): # Für XslFile: Zähle Diff-PDFs für jede XML-Datei if not self.project: return 0 diff_dir = self.project.project_dir / "diff" xsl_id_str = "_".join(str(x) for x in node.id) if node.id else "" for xml_file_obj in node.xmls: xml_stem = xml_file_obj.xml.stem pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf" diff_pdf_path = diff_dir / pdf_basename if diff_pdf_path.exists(): count += 1 elif isinstance(node, TreeNode): # Für TreeNode: Rekursiv alle Kinder durchgehen for i in range(node_item.childCount()): child_item = node_item.child(i) child_node = child_item.data(0, Qt.ItemDataRole.UserRole) if isinstance(child_node, (XslFile, TreeNode)): count += self._count_diff_pdfs_under_node(child_node, child_item) return count def _update_diff_pdf_counts_recursive(self, tree_item: QTreeWidgetItem): """ Aktualisiert rekursiv die Diff-PDF-Anzahl in Spalte 2 für alle TreeNode und XslFile Items. Args: tree_item: Das TreeWidgetItem (kann Root oder beliebiger Knoten sein) """ node = tree_item.data(0, Qt.ItemDataRole.UserRole) # Aktualisiere nur für TreeNode und XslFile, nicht für XmlFile if isinstance(node, (TreeNode, XslFile)): count = self._count_diff_pdfs_under_node(node, tree_item) tree_item.setText(2, str(count) if count > 0 else "") # Rekursiv für alle Kinder for i in range(tree_item.childCount()): child_item = tree_item.child(i) self._update_diff_pdf_counts_recursive(child_item) def _update_all_diff_pdf_counts(self): """ Aktualisiert die Diff-PDF-Anzahl für alle Knoten im TreeWidget. """ root = self.ui.treeWidget.invisibleRootItem() for i in range(root.childCount()): self._update_diff_pdf_counts_recursive(root.child(i)) def _has_xml_files_recursive(self, node: TreeNode) -> bool: """ Prüft rekursiv, ob unter einem TreeNode mindestens eine XML-Datei vorhanden ist. Args: node: Der TreeNode Returns: bool: True wenn mindestens eine XML-Datei gefunden wurde """ if not hasattr(node, "children") or not node.children: return False for child in node.children: if isinstance(child, XslFile): if child.xmls: return True elif isinstance(child, TreeNode): if self._has_xml_files_recursive(child): return True return False def _collect_all_xsl_xml_pairs_recursive( self, tree_node: TreeNode, tree_item: QTreeWidgetItem ) -> list[tuple[XslFile, XmlFile, QTreeWidgetItem]]: """ Sammelt rekursiv alle (XslFile, XmlFile, XslFileItem) Tupel unter einem TreeNode. Args: tree_node: Der TreeNode tree_item: Das TreeWidgetItem des TreeNode Returns: list: Liste von (XslFile, XmlFile, XslFileItem) Tupeln """ pairs = [] if not hasattr(tree_node, "children") or not tree_node.children: return pairs # Durchlaufe alle Kinder des TreeNode for i in range(tree_item.childCount()): child_item = tree_item.child(i) child_node = child_item.data(0, Qt.ItemDataRole.UserRole) if isinstance(child_node, XslFile): # XslFile gefunden - sammle alle XML-Dateien for xml_file_obj in child_node.xmls: pairs.append((child_node, xml_file_obj, child_item)) elif isinstance(child_node, TreeNode): # Rekursiv in Unterknoten suchen pairs.extend(self._collect_all_xsl_xml_pairs_recursive(child_node, child_item)) return pairs def _transform_tree_node(self, item: QTreeWidgetItem, force: bool = False): """ Transformiert alle XML-Dateien unter einem TreeNode (rekursiv). Args: item: Das TreeWidgetItem des TreeNode force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt """ try: # Hole TreeNode-Objekt tree_node_obj = item.data(0, Qt.ItemDataRole.UserRole) if not isinstance(tree_node_obj, TreeNode): logger.error(f"Item ist kein TreeNode: {type(tree_node_obj)}") QMessageBox.warning(self, "Fehler", "Konnte TreeNode nicht ermitteln") return # Prüfe ob XML-Dateien vorhanden sind if not self._has_xml_files_recursive(tree_node_obj): QMessageBox.information(self, "Info", "Keine XML-Dateien unter diesem Knoten gefunden") return # Sammle alle XSL/XML-Paare rekursiv xsl_xml_pairs = self._collect_all_xsl_xml_pairs_recursive(tree_node_obj, item) if not xsl_xml_pairs: QMessageBox.information(self, "Info", "Keine XML-Dateien gefunden") return # Erstelle TransformationJobs für alle XML-Dateien jobs = [] for xsl_file_obj, xml_file_obj, xsl_file_item in xsl_xml_pairs: # Übergebe das XslFile-TreeWidgetItem für Parameter-Sammlung job = self._create_transformation_job(xsl_file_obj, xml_file_obj, xsl_file_item) if job: jobs.append(job) if not jobs: QMessageBox.warning(self, "Fehler", "Konnte keine Transformations-Jobs erstellen") return logger.info(f"Starte Transformation für {len(jobs)} XML-Dateien unter TreeNode '{tree_node_obj.bez}'") # Starte Transformation in separatem Thread self._start_transformation(jobs, force=force) except Exception as e: logger.error(f"Fehler beim Transformieren des TreeNode: {e}") QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}") def _create_transformation_job( self, xsl_file_obj: XslFile, xml_file_obj: XmlFile, xsl_file_item: QTreeWidgetItem | None = None ) -> TransformationJob | None: """ Erstellt einen TransformationJob für eine XML/XSL-Kombination. Args: xsl_file_obj: Das XslFile-Objekt xml_file_obj: Das XmlFile-Objekt xsl_file_item: Optional das TreeWidgetItem des XslFile für hierarchische Parameter-Sammlung 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 # Zusätzliche Sicherheitsprüfung für path_to_binary_file Attribute if java_vm is None or not hasattr(java_vm, "path_to_binary_file") or java_vm.path_to_binary_file is None: QMessageBox.warning(self, "Konfigurationsfehler", "Java VM Pfad ist nicht konfiguriert") return None if saxon_jar is None or not hasattr(saxon_jar, "path_to_jar_file") or saxon_jar.path_to_jar_file is None: QMessageBox.warning(self, "Konfigurationsfehler", "Saxon JAR Pfad ist nicht konfiguriert") return None if apache_fop is None or not hasattr(apache_fop, "path_to_dir") or apache_fop.path_to_dir is None: QMessageBox.warning(self, "Konfigurationsfehler", "Apache FOP Pfad ist nicht konfiguriert") return None if diff_pdf is None or not hasattr(diff_pdf, "path_to_binary_file") or diff_pdf.path_to_binary_file is None: QMessageBox.warning(self, "Konfigurationsfehler", "diff-pdf Pfad ist nicht konfiguriert") return None if xsl_dir is None or not hasattr(xsl_dir, "path_to_root_dir") or xsl_dir.path_to_root_dir is None: QMessageBox.warning(self, "Konfigurationsfehler", "XSL-Verzeichnis Pfad ist nicht konfiguriert") 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 hierarchisch (TreeNode-Eltern → XslFile) xslt_params = {} # 1. Sammle Parameter von übergeordneten TreeNodes (falls TreeWidgetItem verfügbar) if xsl_file_item is not None: parent_params = self._collect_parent_params(xsl_file_item) xslt_params.update(parent_params) logger.debug(f"Hierarchische Parameter gesammelt: {parent_params}") else: logger.warning( "Kein TreeWidgetItem-Kontext verfügbar - " "übergeordnete TreeNode-Parameter werden nicht berücksichtigt" ) # 2. Überschreibe mit XslFile-eigenen Parametern (höchste Priorität) xslt_params.update(xsl_file_obj.xslt_params) logger.info(f"Finale XSLT-Parameter für {xml_file_obj.xml} mit {xsl_file_obj.bez}: {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, fop_config_dir=self.project.fop_config_dir, ) 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, max_workers=app_settings.max_workers) # 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) # Zeige Progressbar self._show_transformation_progress_bar(len(jobs)) # 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 _expand_tree_item_parents(self, item: QTreeWidgetItem): """ Öffnet alle Eltern-Knoten eines Tree-Items rekursiv. Args: item: Das Tree-Item, dessen Eltern geöffnet werden sollen """ if item is None: return # Rekursiv alle Eltern öffnen parent = item.parent() while parent is not None: parent.setExpanded(True) parent = parent.parent() 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 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: # Öffne alle Eltern-Knoten, damit der Benutzer den Fortschritt sehen kann self._expand_tree_item_parents(tree_item) # Scrolle zum Item, damit es sichtbar ist self.ui.treeWidget.scrollToItem(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 und Eltern-Knoten geöffnet") 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 """ # Aktualisiere Transformation-Progressbar self._update_transformation_progress() 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, xsl_id_str) 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 """ # Aktualisiere Transformation-Progressbar self._update_transformation_progress() 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, total_duration: float): """ Signal-Handler: Alle Jobs wurden abgeschlossen. Args: successful_count: Anzahl erfolgreicher Jobs total_count: Gesamtanzahl der Jobs total_duration: Gesamtdauer aller Transformationen in Sekunden """ logger.info( f"Alle Transformationen abgeschlossen: {successful_count}/{total_count} erfolgreich ({total_duration:.2f}s)" ) # Verstecke Transformation-Progressbar self._hide_transformation_progress_bar() # Aktualisiere Diff-PDF-Anzahl und Icons in allen Knoten self._update_all_diff_pdf_counts() self._update_diff_icons_for_existing_pdfs() # Formatiere Dauer für Anzeige duration_str = f"{total_duration:.2f}s" if successful_count == total_count: self.statusBar().showMessage(f"✓ Alle {total_count} Transformationen erfolgreich ({duration_str})", 5000) QMessageBox.information( self, "Abgeschlossen", f"Alle {total_count} Transformationen wurden erfolgreich abgeschlossen.\n\nGesamtdauer: {duration_str}", ) else: failed_count = total_count - successful_count self.statusBar().showMessage( f"⚠ {successful_count}/{total_count} erfolgreich, {failed_count} fehlgeschlagen ({duration_str})", 5000 ) QMessageBox.warning( self, "Abgeschlossen mit Fehlern", f"{successful_count} von {total_count} Transformationen erfolgreich\n{failed_count} fehlgeschlagen\n\nGesamtdauer: {duration_str}", ) def _collect_all_diff_pdfs_under_node( self, node_obj, item: QTreeWidgetItem ) -> list[tuple[Path, str, Path, Path, Path]]: """ Sammelt alle Diff-PDFs unter einem TreeNode oder XslFile. Args: node_obj: TreeNode oder XslFile Objekt item: Das TreeWidgetItem des Knotens Returns: list[tuple]: Liste von (xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path) """ diff_pdfs = [] if not self.project: return diff_pdfs diff_dir = self.project.project_dir / "diff" ref_dir = self.project.project_dir / "ref" new_dir = self.project.project_dir / "new" if not diff_dir.exists(): return diff_pdfs if isinstance(node_obj, TreeNode): # Sammle alle XSL/XML-Paare rekursiv xsl_xml_pairs = self._collect_all_xsl_xml_pairs_recursive(node_obj, item) for xsl_file_obj, xml_file_obj, xsl_file_item in xsl_xml_pairs: # Baue XML-Pfad auf xml_file_path = self.project.project_dir / xml_file_obj.xml # Baue PDF-Namen xml_stem = xml_file_path.stem xsl_id_str = "_".join(str(x) for x in xsl_file_obj.id) if xsl_file_obj.id else "" pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf" diff_pdf_path = diff_dir / pdf_basename ref_pdf_path = ref_dir / pdf_basename new_pdf_path = new_dir / pdf_basename # Prüfe ob Diff-PDF existiert if diff_pdf_path.exists(): diff_pdfs.append((xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path)) elif isinstance(node_obj, XslFile): # Sammle alle XML-Dateien dieser XslFile xsl_id_str = "_".join(str(x) for x in node_obj.id) if node_obj.id else "" for xml_file_obj in node_obj.xmls: # Baue XML-Pfad auf xml_file_path = self.project.project_dir / xml_file_obj.xml # Baue PDF-Namen xml_stem = xml_file_path.stem pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf" diff_pdf_path = diff_dir / pdf_basename ref_pdf_path = ref_dir / pdf_basename new_pdf_path = new_dir / pdf_basename # Prüfe ob Diff-PDF existiert if diff_pdf_path.exists(): diff_pdfs.append((xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path)) return diff_pdfs def _find_next_diff_pdf(self) -> tuple[Path, str] | None: """ Findet die nächste Diff-PDF im Projekt. Returns: tuple[Path, str] | None: (xml_file_path, xsl_id_str) der nächsten Diff-PDF oder None """ if not self.project: return None diff_dir = self.project.project_dir / "diff" if not diff_dir.exists(): return None # Durchlaufe xml_item_map und prüfe welche Items Diff-PDFs haben 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 = parts[0] xsl_id_str = parts[1] # Konvertiere zu Path xml_file_path = Path(xml_path_str) # Prüfe ob Diff-PDF existiert xml_stem = xml_file_path.stem pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf" diff_pdf_path = diff_dir / pdf_basename if diff_pdf_path.exists(): return (xml_file_path, xsl_id_str) return None def _accept_single_diff_pdf( self, xml_file_path: Path, xsl_id_str: str, diff_pdf_path: Path, ref_pdf_path: Path, new_pdf_path: Path ) -> bool: """ Akzeptiert eine einzelne Diff-PDF ohne Viewer-Update. Args: xml_file_path: Pfad zur XML-Datei xsl_id_str: XSL-ID als String diff_pdf_path: Pfad zur Diff-PDF ref_pdf_path: Pfad zur Ref-PDF new_pdf_path: Pfad zur New-PDF Returns: bool: True wenn erfolgreich, False bei Fehler """ pdf_basename = diff_pdf_path.name # Initialisiere am Anfang für Exception-Handler try: # Prüfe ob new-PDF existiert if not new_pdf_path.exists(): logger.warning(f"New-PDF nicht gefunden: {pdf_basename}") return False # Lösche alte ref-PDF falls vorhanden if ref_pdf_path.exists(): ref_pdf_path.unlink() logger.debug(f"Alte Ref-PDF gelöscht: {ref_pdf_path}") # Verschiebe new-PDF nach ref shutil.move(str(new_pdf_path), str(ref_pdf_path)) logger.debug(f"New-PDF verschoben nach Ref: {new_pdf_path} -> {ref_pdf_path}") # Lösche diff-PDF if diff_pdf_path.exists(): diff_pdf_path.unlink() logger.debug(f"Diff-PDF gelöscht: {diff_pdf_path}") # Diff-Icon beim XML-Knoten entfernen # WICHTIG: xml_item_map verwendet relative Pfade, nicht absolute! if self.project and self.project.project_dir: xml_relative_path = xml_file_path.relative_to(self.project.project_dir) else: # Fallback: Verwende absoluten Pfad als String xml_relative_path = xml_file_path map_key = f"{xml_relative_path}|{xsl_id_str}" if map_key in self.xml_item_map: tree_item = self.xml_item_map[map_key] # Entferne Icon-Widget aus Spalte 2 tree_item.setData(2, Qt.ItemDataRole.UserRole, None) self.ui.treeWidget.setItemWidget(tree_item, 2, None) logger.info(f"Änderungen akzeptiert für: {pdf_basename}") return True except Exception as e: logger.error(f"Fehler beim Akzeptieren von {pdf_basename}: {e}") return False def _accept_all_changes_under_node(self, item: QTreeWidgetItem): """ Akzeptiert alle Diff-PDFs unter einem TreeNode oder XslFile. Leert den Viewer, falls eine der akzeptierten PDFs gerade angezeigt wird. Args: item: Das TreeWidgetItem des TreeNode oder XslFile """ try: # Hole Node-Objekt node_obj = item.data(0, Qt.ItemDataRole.UserRole) if not node_obj: logger.warning("Kein Node-Objekt gefunden") return # Sammle alle Diff-PDFs unter diesem Knoten diff_pdfs = self._collect_all_diff_pdfs_under_node(node_obj, item) if not diff_pdfs: QMessageBox.information(self, "Info", "Keine Diff-PDFs unter diesem Knoten gefunden") return # Frage Benutzer um Bestätigung node_name = node_obj.bez if hasattr(node_obj, "bez") else "Unbekannt" reply = QMessageBox.question( self, "Alle Änderungen übernehmen", f"Möchten Sie wirklich alle {len(diff_pdfs)} Änderungen unter '{node_name}' übernehmen?\n\n" f"Dies verschiebt alle new-PDFs nach ref und löscht die diff-PDFs.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return # Merke, ob eine der zu akzeptierenden PDFs gerade im Viewer angezeigt wird viewer_needs_clearing = False if self.current_diff_xml_path and self.current_diff_xsl_id: for xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path in diff_pdfs: if xml_file_path == self.current_diff_xml_path and xsl_id_str == self.current_diff_xsl_id: viewer_needs_clearing = True break # Akzeptiere alle Diff-PDFs successful_count = 0 for xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path in diff_pdfs: if self._accept_single_diff_pdf(xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path): successful_count += 1 # Aktualisiere Diff-PDF-Anzahl auf übergeordneten Ebenen self._update_all_diff_pdf_counts() # Leere Viewer falls nötig if viewer_needs_clearing: self._clear_pdf_viewer() # Zeige Erfolgsmeldung if successful_count == len(diff_pdfs): logger.info(f"Alle {successful_count} Änderungen erfolgreich übernommen") QMessageBox.information( self, "Erfolg", f"Alle {successful_count} Änderungen wurden erfolgreich übernommen" ) else: failed_count = len(diff_pdfs) - successful_count logger.warning( f"{successful_count}/{len(diff_pdfs)} Änderungen übernommen, {failed_count} fehlgeschlagen" ) QMessageBox.warning( self, "Teilweise erfolgreich", f"{successful_count} von {len(diff_pdfs)} Änderungen übernommen\n{failed_count} fehlgeschlagen", ) except Exception as e: logger.error(f"Fehler beim Übernehmen aller Änderungen: {e}") QMessageBox.critical(self, "Fehler", f"Fehler beim Übernehmen der Änderungen:\n{str(e)}") def _close_all_pdf_documents(self): """Schließt alle geöffneten PDF-Dokumente explizit (wichtig für Windows).""" import gc if self.pdf_documents: for pdf_basename, docs in self.pdf_documents.items(): for doc_type, doc in docs.items(): if doc: doc.close() logger.debug(f"PDF-Dokument geschlossen: {pdf_basename} ({doc_type})") # Lösche alle Referenzen self.pdf_documents.clear() # Lösche gerenderte Pixmaps self.current_rendered_pixmaps = None # Erzwinge Garbage Collection um Dateihandles freizugeben (wichtig für Windows) gc.collect() logger.info("Alle PDF-Dokumente geschlossen und Referenzen freigegeben") def _clear_pdf_viewer(self): """Leert den PDF-Viewer und alle Thumbnails.""" # Schließe alle PDF-Dokumente explizit (wichtig für Windows) self._close_all_pdf_documents() # Entferne Widgets aus Layouts self._clear_layout(self.ui.verticalLayout_2) self._clear_layout(self.ui.verticalLayout_3) # Zurücksetzen der Datenstrukturen self.thumbnail_to_page = {} self.pdf_documents = {} self.current_rendered_pixmaps = None self.fullsize_label = None self.current_pdf = None self.current_diff_xml_path = None self.current_diff_xsl_id = None logger.info("PDF-Viewer geleert") def _on_accept_changes_clicked(self): """ Handler für Accept-Changes-Button. Verschiebt new PDF nach ref, löscht diff PDF, aktualisiert Icons und lädt nächste Diff-PDF. """ try: if not self.project or not self.current_diff_xml_path or not self.current_diff_xsl_id: logger.warning("Keine aktuelle Diff-PDF zum Akzeptieren") return # PDF-Dateinamen ermitteln xml_stem = self.current_diff_xml_path.stem pdf_basename = f"{xml_stem}_xsl_{self.current_diff_xsl_id}.pdf" # Pfade diff_dir = self.project.project_dir / "diff" ref_dir = self.project.project_dir / "ref" new_dir = self.project.project_dir / "new" diff_pdf_path = diff_dir / pdf_basename ref_pdf_path = ref_dir / pdf_basename new_pdf_path = new_dir / pdf_basename logger.info(f"Akzeptiere Änderungen für: {pdf_basename}") # Prüfe ob new-PDF existiert if not new_pdf_path.exists(): QMessageBox.warning(self, "Fehler", f"New-PDF nicht gefunden:\n{pdf_basename}") return # Schließe alle PDF-Dokumente und leere UI VOR dem Löschen/Verschieben (wichtig für Windows) self._close_all_pdf_documents() # Entferne auch alle Widgets, die Pixmaps enthalten könnten self._clear_layout(self.ui.verticalLayout_2) self._clear_layout(self.ui.verticalLayout_3) self.thumbnail_to_page = {} self.fullsize_label = None # Verarbeite alle pending Qt Events um sicherzustellen, dass Widgets/Ressourcen freigegeben werden from PySide6.QtWidgets import QApplication QApplication.processEvents() logger.info("PDF-Dokumente geschlossen und UI geleert vor Dateioperationen") # Lösche alte ref-PDF falls vorhanden if ref_pdf_path.exists(): ref_pdf_path.unlink() logger.info(f"Alte Ref-PDF gelöscht: {ref_pdf_path}") # Verschiebe new-PDF nach ref shutil.move(str(new_pdf_path), str(ref_pdf_path)) logger.info(f"New-PDF verschoben nach Ref: {new_pdf_path} -> {ref_pdf_path}") # Lösche diff-PDF if diff_pdf_path.exists(): diff_pdf_path.unlink() logger.info(f"Diff-PDF gelöscht: {diff_pdf_path}") # Diff-Icon beim aktuellen XML-Knoten entfernen map_key = f"{self.current_diff_xml_path}|{self.current_diff_xsl_id}" if map_key in self.xml_item_map: tree_item = self.xml_item_map[map_key] # Entferne Icon-Widget aus Spalte 2 tree_item.setData(2, Qt.ItemDataRole.UserRole, None) self.ui.treeWidget.setItemWidget(tree_item, 2, None) logger.info(f"Diff-Icon entfernt für: {map_key}") # Diff-PDF-Anzahl auf übergeordneten Ebenen aktualisieren self._update_all_diff_pdf_counts() # Finde nächste Diff-PDF next_diff = self._find_next_diff_pdf() if next_diff: # Lade nächste Diff-PDF next_xml_path, next_xsl_id = next_diff logger.info(f"Lade nächste Diff-PDF: {next_xml_path} / {next_xsl_id}") self._load_pdf_for_comparison(next_xml_path, next_xsl_id) # Wähle den entsprechenden XML-Knoten im Baum aus map_key = f"{next_xml_path}|{next_xsl_id}" if map_key in self.xml_item_map: tree_item = self.xml_item_map[map_key] self.ui.treeWidget.setCurrentItem(tree_item) self.ui.treeWidget.scrollToItem(tree_item) logger.info(f"TreeWidget-Item ausgewählt: {map_key}") else: # Keine weiteren Diff-PDFs, Button deaktivieren und Viewer leeren logger.info("Keine weiteren Diff-PDFs vorhanden") self.ui.accept_changes.setEnabled(False) self._clear_pdf_viewer() except Exception as e: logger.error(f"Fehler beim Akzeptieren der Änderungen: {e}") QMessageBox.critical(self, "Fehler", f"Fehler beim Akzeptieren der Änderungen:\n{str(e)}") def closeEvent(self, event): """Wird beim Schließen der Anwendung aufgerufen.""" # UI-Zustände speichern self._save_ui_state() # 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() # Beende Saxon-Worker-Pool self._shutdown_saxon_worker_pool() # PDF-Dokumente schließen ist bei QtPdf automatisch durch Garbage Collection super().closeEvent(event) def _save_ui_state(self): """Speichert die aktuellen UI-Zustände (Fenstergeometrie, Splitter, TreeWidget-Spalten).""" global app_settings # Fenstergeometrie speichern geometry = self.geometry() app_settings.window_geometry = (geometry.x(), geometry.y(), geometry.width(), geometry.height()) # Splitter-Positionen speichern app_settings.splitter_sizes = self.ui.splitter.sizes() # TreeWidget-Spaltenbreiten speichern column_count = self.ui.treeWidget.columnCount() app_settings.tree_column_widths = [self.ui.treeWidget.columnWidth(i) for i in range(column_count)] # Konfiguration speichern app_settings.save()