diff --git a/src/ui/MainWindow.py b/src/ui/MainWindow.py index 683345a..d6764e5 100644 --- a/src/ui/MainWindow.py +++ b/src/ui/MainWindow.py @@ -1,483 +1,46 @@ -import time -import polars as pl -import shutil -import hashlib import logging -from typing import List +import shutil -from PySide6.QtCore import Qt, QSize, QThread, Signal, QUrl, QEvent -from PySide6.QtGui import QCursor, QPixmap, QPainter, QAction, QIcon, QDragEnterEvent, QDropEvent, QDesktopServices +from PySide6.QtCore import Qt, QUrl, QEvent +from PySide6.QtGui import QAction, QDesktopServices from PySide6.QtWidgets import ( - QLabel, QMainWindow, QApplication, QStyleFactory, - QMenu, QTreeWidgetItem, QMessageBox, - QFileDialog, - QWidget, - QHBoxLayout, - QProgressBar, + QMenu, ) -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, XsltVersion -from transform import TransformationJob, set_saxon_worker_pool -from saxon_pool import SaxonWorkerPool -from saxon_pool_s9api import SaxonWorkerPoolS9Api +from ui.mixins import ( + TreeManagerMixin, + PdfViewerMixin, + WorkerPoolMixin, + DatabaseMixin, + DragDropMixin, + HashCalculationMixin, + TransformationMixin, +) +from conf import app_settings, Project, ProjectData, TreeNode, XslFile 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): +class MainWindow( + QMainWindow, + TreeManagerMixin, + PdfViewerMixin, + WorkerPoolMixin, + DatabaseMixin, + DragDropMixin, + HashCalculationMixin, + TransformationMixin, +): def __init__(self, parent=None): """ Konstruktor für die MainWindow-Klasse. @@ -705,161 +268,6 @@ class MainWindow(QMainWindow): 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() - - # Prüfe ob SaxonWorkerPool aktiviert ist - if not app_settings.use_saxon_worker_pool: - logger.info("SaxonWorkerPool deaktiviert - Verwende Fallback-Modus (subprocess)") - return - - 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 (wähle richtige Variante basierend auf XSLT-Version) - num_workers = app_settings.max_workers - log_dir = self.project.project_dir / "temp" - - # Wähle die richtige Worker-Pool-Implementierung - if app_settings.saxon_xslt_version == XsltVersion.XSLT_1_0: - # JAXP-basierte Variante für XSLT 1.0 - 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, - ) - pool_type = "JAXP (XSLT 1.0)" - else: - # s9api-basierte Variante für XSLT 2.0/3.0 - pool = SaxonWorkerPoolS9Api( - 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, - ) - pool_type = "s9api (XSLT 2.0/3.0)" - - # Setze globalen Pool - set_saxon_worker_pool(pool) - - logger.info( - f"Saxon-Worker-Pool initialisiert: {num_workers} Worker mit {pool_type} " - 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}") - logger.info("Fallback auf subprocess-Modus") - # 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 _initialize_fop_worker_pool(self): - """Initialisiert den FOP-Worker-Pool für schnelle PDF-Generierung.""" - try: - # Shutdown vorherigen Pool falls vorhanden - self._shutdown_fop_worker_pool() - - # Prüfe ob FopWorkerPool aktiviert ist - if not app_settings.use_fop_worker_pool: - logger.info("FopWorkerPool deaktiviert - Verwende Fallback-Modus (subprocess)") - return - - if not self.project: - logger.warning("Kein Projekt geladen, FOP-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) - apache_fop = next((fop for fop in app_settings.apache_fops if fop.id == self.project.apache_fop_id), None) - - if not java_vm or not apache_fop: - logger.warning("Java VM oder Apache FOP nicht gefunden, Pool nicht initialisiert") - return - - # FOP-Konfigurationsdatei (falls vorhanden) - fop_config_file = None - if self.project.fop_config_dir: - fop_config_file = self.project.fop_config_dir / "fop.xconf" - else: - default_config = apache_fop.path_to_dir / "conf" / "fop.xconf" - if default_config.exists(): - fop_config_file = default_config - - # Importiere FopWorkerPool - from fop_pool import FopWorkerPool - from transform import set_fop_worker_pool - - # Erstelle Worker-Pool - num_workers = app_settings.max_workers - log_dir = self.project.project_dir / "temp" - pool = FopWorkerPool( - num_workers=num_workers, - java_vm_path=java_vm.path_to_binary_file, - apache_fop_dir=apache_fop.path_to_dir, - fop_config_file=fop_config_file, - log_dir=log_dir, - ) - - # Setze globalen Pool - set_fop_worker_pool(pool) - - logger.info( - f"FOP-Worker-Pool initialisiert: {num_workers} Worker " - f"(erwartet: {num_workers}x schneller für PDF-Generierung)" - ) - - except Exception as e: - logger.error(f"Fehler beim Initialisieren des FOP-Worker-Pools: {e}") - logger.info("Fallback auf subprocess-Modus") - # Kein Pool ist OK - Fallback auf subprocess - - def _shutdown_fop_worker_pool(self): - """Beendet den FOP-Worker-Pool sauber.""" - try: - # Importiere transform um Zugriff auf globalen Pool zu haben - import transform - - if transform._fop_worker_pool: - logger.info("Beende FOP-Worker-Pool...") - transform._fop_worker_pool.shutdown() - # Importiere set_fop_worker_pool - from transform import set_fop_worker_pool - - set_fop_worker_pool(None) - logger.info("FOP-Worker-Pool beendet") - - except Exception as e: - logger.error(f"Fehler beim Beenden des FOP-Worker-Pools: {e}") - def change_theme(self, theme_name): """ Wechselt das Theme der Anwendung. @@ -889,178 +297,6 @@ class MainWindow(QMainWindow): 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 @@ -1107,459 +343,6 @@ class MainWindow(QMainWindow): self.ui.view_ref_pdf.clicked.connect(self._on_view_ref_pdf_clicked) self.ui.view_new_pdf.clicked.connect(self._on_view_new_pdf_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() - - # Ref-PDF öffnen Aktion (nur enabled wenn Ref-PDF existiert und keine Diff-PDF) - xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole) - parent_item = item.parent() - ref_pdf_can_open = False - - if xml_file_obj and parent_item and self.project: - xsl_file_obj = parent_item.data(0, Qt.ItemDataRole.UserRole) - if xsl_file_obj: - # Erstelle Pfade zu Ref-PDF und Diff-PDF - xsl_id_str = "_".join(map(str, xsl_file_obj.id)) - xml_stem = xml_file_obj.xml.stem - pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf" - - ref_pdf_path = self.project.project_dir / "ref" / pdf_basename - diff_pdf_path = self.project.project_dir / "diff" / pdf_basename - - # Ref-PDF kann geöffnet werden, wenn sie existiert und keine Diff-PDF vorhanden ist - ref_pdf_can_open = ref_pdf_path.exists() and not diff_pdf_path.exists() - - action_open_ref_pdf = QAction("Ref-PDF öffnen", self) - action_open_ref_pdf.setIcon(QIcon(QIcon.fromTheme("document-open"))) - action_open_ref_pdf.triggered.connect(lambda: self._open_ref_pdf_for_xml_file(item)) - action_open_ref_pdf.setEnabled(ref_pdf_can_open) - menu.addAction(action_open_ref_pdf) - - 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: @@ -1571,17 +354,6 @@ class MainWindow(QMainWindow): except Exception as e: logger.error(f"Fehler beim Öffnen des Einstellungen-Dialogs: {e}") - def _show_worker_pool_metrics(self): - """Zeigt den Worker-Pool-Metriken-Dialog an.""" - try: - from ui.WorkerPoolMetricsDialog import WorkerPoolMetricsDialog - - dialog = WorkerPoolMetricsDialog(self) - dialog.exec() - except Exception as e: - logger.error(f"Fehler beim Öffnen des Metriken-Dialogs: {e}") - QMessageBox.critical(self, "Fehler", f"Fehler beim Öffnen des Metriken-Dialogs:\n{str(e)}") - def open_new_project_dialog(self): """Öffnet Pdf-Projekt-Dialog.""" try: @@ -1667,427 +439,6 @@ class MainWindow(QMainWindow): """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} - - # PDF-Pfade für System-Viewer speichern - self.current_ref_pdf_path = ref_pdf_path - self.current_new_pdf_path = new_pdf_path - - # Buttons zum Öffnen der PDFs im System-Viewer aktivieren - self.ui.view_ref_pdf.setEnabled(True) - self.ui.view_new_pdf.setEnabled(True) - - # Slider aktivieren - self.ui.alpha.setEnabled(True) - self.ui.zoom.setEnabled(True) - - 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. @@ -2129,760 +480,6 @@ class MainWindow(QMainWindow): 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 _setup_scroll_area_zoom(self): """Aktiviert Zoom per STRG+Mausrad für die PDF-ScrollArea.""" try: @@ -2893,1561 +490,6 @@ class MainWindow(QMainWindow): except Exception as e: logger.error(f"Fehler beim Aktivieren von Scroll-Area-Zoom: {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)) - - # Initialisiere Worker-Pools (lazy loading - nur wenn benötigt) - self._initialize_saxon_worker_pool() - self._initialize_fop_worker_pool() - - # Erfasse RAM-Verbrauch vor Transformation - import transform - - if transform._saxon_worker_pool: - transform._saxon_worker_pool.capture_ram_before_transform() - if transform._fop_worker_pool: - transform._fop_worker_pool.capture_ram_before_transform() - - # 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)" - ) - - # Erfasse RAM-Verbrauch nach Transformation - import transform - from copy import deepcopy - - if transform._saxon_worker_pool: - transform._saxon_worker_pool.capture_ram_after_transform() - # Speichere Metriken vor Shutdown (für späteren Zugriff im Dialog) - self.last_saxon_metrics = deepcopy(transform._saxon_worker_pool.metrics) - logger.debug("Saxon Worker-Pool Metriken gespeichert") - - if transform._fop_worker_pool: - transform._fop_worker_pool.capture_ram_after_transform() - # Speichere Metriken vor Shutdown (für späteren Zugriff im Dialog) - self.last_fop_metrics = deepcopy(transform._fop_worker_pool.metrics) - logger.debug("FOP Worker-Pool Metriken gespeichert") - - # Beende Worker-Pools (RAM-Optimierung - Pools werden bei nächster Transformation neu gestartet) - self._shutdown_saxon_worker_pool() - self._shutdown_fop_worker_pool() - - # 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]]: @@ -4691,58 +733,6 @@ class MainWindow(QMainWindow): 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 - - # PDF-Pfade zurücksetzen und Buttons deaktivieren - self.current_ref_pdf_path = None - self.current_new_pdf_path = None - self.ui.view_ref_pdf.setEnabled(False) - self.ui.view_new_pdf.setEnabled(False) - - # Slider deaktivieren - self.ui.alpha.setEnabled(False) - self.ui.zoom.setEnabled(False) - - logger.info("PDF-Viewer geleert") - def _on_accept_changes_clicked(self): """ Handler für Accept-Changes-Button. @@ -4841,38 +831,6 @@ class MainWindow(QMainWindow): logger.error(f"Fehler beim Akzeptieren der Änderungen: {e}") QMessageBox.critical(self, "Fehler", f"Fehler beim Akzeptieren der Änderungen:\n{str(e)}") - def _on_view_ref_pdf_clicked(self): - """ - Handler für view_ref_pdf Button. - Öffnet die Referenz-PDF im systemseitig installierten PDF-Viewer. - """ - if not self.current_ref_pdf_path or not self.current_ref_pdf_path.exists(): - QMessageBox.warning(self, "Fehler", "Referenz-PDF nicht gefunden") - logger.warning("Referenz-PDF nicht verfügbar") - return - - logger.info(f"Öffne Referenz-PDF im System-Viewer: {self.current_ref_pdf_path}") - url = QUrl.fromLocalFile(str(self.current_ref_pdf_path)) - if not QDesktopServices.openUrl(url): - QMessageBox.critical(self, "Fehler", f"Konnte Referenz-PDF nicht öffnen:\n{self.current_ref_pdf_path}") - logger.error(f"Fehler beim Öffnen der Referenz-PDF: {self.current_ref_pdf_path}") - - def _on_view_new_pdf_clicked(self): - """ - Handler für view_new_pdf Button. - Öffnet die neue PDF im systemseitig installierten PDF-Viewer. - """ - if not self.current_new_pdf_path or not self.current_new_pdf_path.exists(): - QMessageBox.warning(self, "Fehler", "Neue PDF nicht gefunden") - logger.warning("Neue PDF nicht verfügbar") - return - - logger.info(f"Öffne neue PDF im System-Viewer: {self.current_new_pdf_path}") - url = QUrl.fromLocalFile(str(self.current_new_pdf_path)) - if not QDesktopServices.openUrl(url): - QMessageBox.critical(self, "Fehler", f"Konnte neue PDF nicht öffnen:\n{self.current_new_pdf_path}") - logger.error(f"Fehler beim Öffnen der neuen PDF: {self.current_new_pdf_path}") - def _open_ref_pdf_for_xml_file(self, item): """ Handler für Kontextmenü-Aktion "Ref-PDF öffnen" bei XML-Dateien. diff --git a/src/ui/mixins/__init__.py b/src/ui/mixins/__init__.py new file mode 100644 index 0000000..68a5de5 --- /dev/null +++ b/src/ui/mixins/__init__.py @@ -0,0 +1,24 @@ +""" +Mixins für das MainWindow. + +Dieses Paket enthält Mixins, die Funktionalität in separate Module auslagern, +um die MainWindow-Klasse übersichtlicher zu gestalten. +""" + +from ui.mixins.tree_manager import TreeManagerMixin +from ui.mixins.pdf_viewer import PdfViewerMixin +from ui.mixins.worker_pool import WorkerPoolMixin +from ui.mixins.database import DatabaseMixin +from ui.mixins.drag_drop import DragDropMixin +from ui.mixins.hash_calculation import HashCalculationMixin +from ui.mixins.transformation import TransformationMixin + +__all__ = [ + "TreeManagerMixin", + "PdfViewerMixin", + "WorkerPoolMixin", + "DatabaseMixin", + "DragDropMixin", + "HashCalculationMixin", + "TransformationMixin", +] diff --git a/src/ui/mixins/database.py b/src/ui/mixins/database.py new file mode 100644 index 0000000..597c88f --- /dev/null +++ b/src/ui/mixins/database.py @@ -0,0 +1,288 @@ +""" +DatabaseMixin - Mixin für Datenbank-Operationen. + +Dieses Mixin enthält alle Methoden zur PostgreSQL-Datenbankanbindung +und Datenverarbeitung für das MainWindow. +""" + +import time +import logging +from pathlib import Path + +import polars as pl +from PySide6.QtWidgets import QMessageBox + +from conf import app_settings, TreeNode, XslFile + +logger = logging.getLogger(__name__) + + +class DatabaseMixin: + """ + Mixin für Datenbank-Operationen. + + Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute: + - self.project: Das aktuelle Projekt + - self.pdf_project: Die Projekt-Daten (ProjectData) + - self._load_nodes_to_tree(): Methode zum Laden der Nodes in den Tree + - self._save_project_settings(): Methode zum Speichern der Projekt-Einstellungen + """ + + 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}") diff --git a/src/ui/mixins/drag_drop.py b/src/ui/mixins/drag_drop.py new file mode 100644 index 0000000..33889f3 --- /dev/null +++ b/src/ui/mixins/drag_drop.py @@ -0,0 +1,346 @@ +""" +DragDropMixin - Mixin für Drag-and-Drop-Funktionalität. + +Dieses Mixin enthält alle Methoden zur Verarbeitung von Drag-and-Drop-Operationen +für das MainWindow. +""" + +import logging +from pathlib import Path + +from PySide6.QtGui import QDragEnterEvent, QDropEvent +from PySide6.QtWidgets import QMessageBox, QProgressBar + +from ui.XmlToXslAssignDialog import XmlToXslAssignDialog +from ui.threads import XmlBatchProcessingThread + +logger = logging.getLogger(__name__) + + +class DragDropMixin: + """ + Mixin für Drag-and-Drop-Funktionalität. + + Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute: + - self.ui: Das UI-Objekt mit treeWidget + - self.project: Das aktuelle Projekt + - self.pdf_project: Die Projekt-Daten (ProjectData) + - self.batch_processing_thread: Thread für Batch-Verarbeitung + - self.batch_progress_bar: QProgressBar für Batch-Fortschritt + - self._save_project_settings(): Methode zum Speichern der Projekt-Einstellungen + - self._load_nodes_to_tree(): Methode zum Laden der Nodes in den Tree + """ + + 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_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 hinzugefuegt: {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("\nUmbenannte Dateien:") + for renamed in stats["renamed_files"]: + summary_lines.append(f" - {renamed}") + + if stats["errors"] > 0: + summary_lines.append(f"\nFehler: {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) diff --git a/src/ui/mixins/hash_calculation.py b/src/ui/mixins/hash_calculation.py new file mode 100644 index 0000000..4789d11 --- /dev/null +++ b/src/ui/mixins/hash_calculation.py @@ -0,0 +1,655 @@ +""" +HashCalculationMixin - Mixin für Hash-Berechnung und XML-Dateiverwaltung. + +Dieses Mixin enthält alle Methoden zur blake2b-Hash-Berechnung, +XML-Datei-Zuordnung und Duplikatserkennung für das MainWindow. +""" + +import time +import hashlib +import shutil +import logging +from pathlib import Path +from typing import List + +from PySide6.QtWidgets import QMessageBox + +from conf import TreeNode, XslFile, XmlFile +from ui.XmlToXslAssignDialog import XmlToXslAssignDialog +from ui.threads import XmlHashCalculatorThread + +logger = logging.getLogger(__name__) + + +class HashCalculationMixin: + """ + Mixin für Hash-Berechnung und XML-Dateiverwaltung. + + Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute: + - self.project: Das aktuelle Projekt + - self.pdf_project: Die Projekt-Daten (ProjectData) + - self.hash_calculator_thread: Thread für Hash-Berechnung + - self._save_project_settings(): Methode zum Speichern der Projekt-Einstellungen + - self._load_nodes_to_tree(): Methode zum Laden der Nodes in den Tree + """ + + 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 diff --git a/src/ui/mixins/pdf_viewer.py b/src/ui/mixins/pdf_viewer.py new file mode 100644 index 0000000..e1d7abb --- /dev/null +++ b/src/ui/mixins/pdf_viewer.py @@ -0,0 +1,547 @@ +""" +PdfViewerMixin - Mixin für PDF-Viewer-Operationen. + +Dieses Mixin enthält alle Methoden für die PDF-Anzeige und -Vergleich im MainWindow: +- PDF-Rendering und -Anzeige +- Alpha-Blending und Zoom +- Thumbnail-Navigation +- Drag-to-Scroll +- PDF-Dokument-Management +""" + +import gc +import logging +import time +from pathlib import Path + +from PySide6.QtCore import Qt, QSize, QUrl +from PySide6.QtGui import QCursor, QPixmap, QPainter, QDesktopServices +from PySide6.QtWidgets import QLabel, QMessageBox +from PySide6.QtPdf import QPdfDocument + + +logger = logging.getLogger(__name__) + + +class PdfViewerMixin: + """ + Mixin-Klasse für PDF-Viewer-Operationen. + + Dieses Mixin erwartet, dass die verwendende Klasse folgende Attribute hat: + - self.ui: UI-Objekt mit Layouts, Slidern, etc. + - self.project: Aktuelles Projekt + - self.pdf_documents: Dict für PDF-Dokumente + - self.current_rendered_pixmaps: Cache für gerenderte Pixmaps + - self.fullsize_label: QLabel für Vollbild-Anzeige + - self.thumbnail_to_page: Dict für Thumbnail-zu-Seite-Mapping + - self.current_zoom: Aktueller Zoom-Faktor + - self.current_page: Aktuelle Seitennummer + - self.current_pdf: Aktueller PDF-Dateiname + - self.is_dragging: Drag-Status + - self.last_drag_position: Letzte Drag-Position + - self.drag_threshold: Mindestbewegung für Drag + - self.scroll_sensitivity: Scroll-Empfindlichkeit + """ + + 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 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 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_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} + + # PDF-Pfade für System-Viewer speichern + self.current_ref_pdf_path = ref_pdf_path + self.current_new_pdf_path = new_pdf_path + + # Buttons zum Öffnen der PDFs im System-Viewer aktivieren + self.ui.view_ref_pdf.setEnabled(True) + self.ui.view_new_pdf.setEnabled(True) + + # Slider aktivieren + self.ui.alpha.setEnabled(True) + self.ui.zoom.setEnabled(True) + + 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 _close_all_pdf_documents(self): + """Schließt alle geöffneten PDF-Dokumente explizit (wichtig für Windows).""" + 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 + + # PDF-Pfade zurücksetzen und Buttons deaktivieren + self.current_ref_pdf_path = None + self.current_new_pdf_path = None + self.ui.view_ref_pdf.setEnabled(False) + self.ui.view_new_pdf.setEnabled(False) + + # Slider deaktivieren + self.ui.alpha.setEnabled(False) + self.ui.zoom.setEnabled(False) + + logger.info("PDF-Viewer geleert") + + def _on_view_ref_pdf_clicked(self): + """ + Handler für view_ref_pdf Button. + Öffnet die Referenz-PDF im systemseitig installierten PDF-Viewer. + """ + if not self.current_ref_pdf_path or not self.current_ref_pdf_path.exists(): + QMessageBox.warning(self, "Fehler", "Referenz-PDF nicht gefunden") + logger.warning("Referenz-PDF nicht verfügbar") + return + + logger.info(f"Öffne Referenz-PDF im System-Viewer: {self.current_ref_pdf_path}") + url = QUrl.fromLocalFile(str(self.current_ref_pdf_path)) + if not QDesktopServices.openUrl(url): + QMessageBox.critical(self, "Fehler", f"Konnte Referenz-PDF nicht öffnen:\n{self.current_ref_pdf_path}") + logger.error(f"Fehler beim Öffnen der Referenz-PDF: {self.current_ref_pdf_path}") + + def _on_view_new_pdf_clicked(self): + """ + Handler für view_new_pdf Button. + Öffnet die neue PDF im systemseitig installierten PDF-Viewer. + """ + if not self.current_new_pdf_path or not self.current_new_pdf_path.exists(): + QMessageBox.warning(self, "Fehler", "Neue PDF nicht gefunden") + logger.warning("Neue PDF nicht verfügbar") + return + + logger.info(f"Öffne neue PDF im System-Viewer: {self.current_new_pdf_path}") + url = QUrl.fromLocalFile(str(self.current_new_pdf_path)) + if not QDesktopServices.openUrl(url): + QMessageBox.critical(self, "Fehler", f"Konnte neue PDF nicht öffnen:\n{self.current_new_pdf_path}") + logger.error(f"Fehler beim Öffnen der neuen PDF: {self.current_new_pdf_path}") diff --git a/src/ui/mixins/transformation.py b/src/ui/mixins/transformation.py new file mode 100644 index 0000000..7197f0f --- /dev/null +++ b/src/ui/mixins/transformation.py @@ -0,0 +1,680 @@ +""" +TransformationMixin - Mixin für XSL-Transformationen. + +Dieses Mixin enthält alle Methoden zur Durchführung und Verwaltung von +XSL-Transformationen für das MainWindow. +""" + +import logging +from copy import deepcopy +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMessageBox, QProgressBar, QTreeWidgetItem + +from conf import app_settings, TreeNode, XslFile, XmlFile +from transform import TransformationJob +from ui.threads import TransformationThread + +logger = logging.getLogger(__name__) + + +class TransformationMixin: + """ + Mixin für XSL-Transformationen. + + Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute: + - self.project: Das aktuelle Projekt + - self.ui: Das UI-Objekt mit treeWidget, statusBar + - self.transformation_thread: Thread für Transformationen + - self.transformation_progress_bar: QProgressBar für Transformation-Fortschritt + - self.xml_item_map: Mapping von xml_path|xsl_id zu TreeWidgetItems + - self.last_saxon_metrics: Gecachte Saxon-Worker-Pool-Metriken + - self.last_fop_metrics: Gecachte FOP-Worker-Pool-Metriken + + Erwartet folgende Methoden von anderen Mixins: + - self._initialize_saxon_worker_pool(): Von WorkerPoolMixin + - self._initialize_fop_worker_pool(): Von WorkerPoolMixin + - self._shutdown_saxon_worker_pool(): Von WorkerPoolMixin + - self._shutdown_fop_worker_pool(): Von WorkerPoolMixin + - self._create_centered_progress_bar(): Von TreeManagerMixin + - self._create_centered_diff_icon(): Von TreeManagerMixin + - self._collect_parent_params(): Von TreeManagerMixin + - self._update_diff_icons_for_existing_pdfs(): Von MainWindow + """ + + 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 _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)) + + # Initialisiere Worker-Pools (lazy loading - nur wenn benötigt) + self._initialize_saxon_worker_pool() + self._initialize_fop_worker_pool() + + # Erfasse RAM-Verbrauch vor Transformation + import transform + + if transform._saxon_worker_pool: + transform._saxon_worker_pool.capture_ram_before_transform() + if transform._fop_worker_pool: + transform._fop_worker_pool.capture_ram_before_transform() + + # 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)" + ) + + # Erfasse RAM-Verbrauch nach Transformation + import transform + + if transform._saxon_worker_pool: + transform._saxon_worker_pool.capture_ram_after_transform() + # Speichere Metriken vor Shutdown (für späteren Zugriff im Dialog) + self.last_saxon_metrics = deepcopy(transform._saxon_worker_pool.metrics) + logger.debug("Saxon Worker-Pool Metriken gespeichert") + + if transform._fop_worker_pool: + transform._fop_worker_pool.capture_ram_after_transform() + # Speichere Metriken vor Shutdown (für späteren Zugriff im Dialog) + self.last_fop_metrics = deepcopy(transform._fop_worker_pool.metrics) + logger.debug("FOP Worker-Pool Metriken gespeichert") + + # Beende Worker-Pools (RAM-Optimierung - Pools werden bei nächster Transformation neu gestartet) + self._shutdown_saxon_worker_pool() + self._shutdown_fop_worker_pool() + + # 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}", + ) diff --git a/src/ui/mixins/tree_manager.py b/src/ui/mixins/tree_manager.py new file mode 100644 index 0000000..b71a0f8 --- /dev/null +++ b/src/ui/mixins/tree_manager.py @@ -0,0 +1,1136 @@ +""" +TreeManagerMixin - Mixin für Tree-Widget-Operationen. + +Dieses Mixin enthält alle Methoden für die Verwaltung des TreeWidgets im MainWindow: +- Setup und Styling +- Kontextmenüs +- Node-Operationen (Hinzufügen, Bearbeiten, Löschen) +- Tree-Navigation und -Suche +""" + +import logging +import shutil +import time +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QIcon +from PySide6.QtWidgets import ( + QMenu, + QTreeWidgetItem, + QMessageBox, + QFileDialog, + QWidget, + QHBoxLayout, + QLabel, + QProgressBar, +) + +from conf import TreeNode, XslFile, XmlFile +from ui.TreeNodeEditDialog import TreeNodeEditDialog +from ui.XslFileEditDialog import XslFileEditDialog + + +logger = logging.getLogger(__name__) + + +class TreeManagerMixin: + """ + Mixin-Klasse für Tree-Widget-Operationen. + + Dieses Mixin erwartet, dass die verwendende Klasse folgende Attribute hat: + - self.ui: UI-Objekt mit treeWidget + - self.project: Aktuelles Projekt + - self.pdf_project: Projekt-Daten + - self.xml_item_map: Dict für XML-Item-Mapping + """ + + def _setup_tree_context_menu(self): + """Richtet das Kontextmenü für das TreeWidget ein.""" + # Aktiviere Kontextmenü für das TreeWidget + self.ui.treeWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.ui.treeWidget.customContextMenuRequested.connect(self._show_tree_context_menu) + + # Verbinde Selection-Changed-Signal für automatisches Laden von Diff-PDFs + self.ui.treeWidget.itemSelectionChanged.connect(self._on_tree_selection_changed) + + logger.debug("Kontextmenü und Selection-Handler für TreeWidget eingerichtet") + + def _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() + + # Ref-PDF öffnen Aktion (nur enabled wenn Ref-PDF existiert und keine Diff-PDF) + xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole) + parent_item = item.parent() + ref_pdf_can_open = False + + if xml_file_obj and parent_item and self.project: + xsl_file_obj = parent_item.data(0, Qt.ItemDataRole.UserRole) + if xsl_file_obj: + # Erstelle Pfade zu Ref-PDF und Diff-PDF + xsl_id_str = "_".join(map(str, xsl_file_obj.id)) + xml_stem = xml_file_obj.xml.stem + pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf" + + ref_pdf_path = self.project.project_dir / "ref" / pdf_basename + diff_pdf_path = self.project.project_dir / "diff" / pdf_basename + + # Ref-PDF kann geöffnet werden, wenn sie existiert und keine Diff-PDF vorhanden ist + ref_pdf_can_open = ref_pdf_path.exists() and not diff_pdf_path.exists() + + action_open_ref_pdf = QAction("Ref-PDF öffnen", self) + action_open_ref_pdf.setIcon(QIcon(QIcon.fromTheme("document-open"))) + action_open_ref_pdf.triggered.connect(lambda: self._open_ref_pdf_for_xml_file(item)) + action_open_ref_pdf.setEnabled(ref_pdf_can_open) + menu.addAction(action_open_ref_pdf) + + menu.addSeparator() + + action_edit = QAction("Bearbeiten", self) + action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties))) + action_edit.triggered.connect(lambda: self._edit_xml_file(item)) + menu.addAction(action_edit) + + action_delete = QAction("Löschen", self) + action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete))) + action_delete.triggered.connect(lambda: self._delete_xml_file(item)) + menu.addAction(action_delete) + + else: + # Unbekannter Typ oder leerer Bereich - Menü für Root-Elemente + action_add_tree_node = QAction("Unterknoten hinzufügen", self) + action_add_tree_node.setIcon(QIcon(QIcon.fromTheme("folder-new"))) + action_add_tree_node.triggered.connect(lambda: self._add_root_tree_node()) + menu.addAction(action_add_tree_node) + + return menu + + except Exception as e: + logger.error(f"Fehler beim Erstellen des Kontextmenüs: {e}") + return None + + def _load_nodes_to_tree(self): + """ + Lädt die Nodes aus den Projekt-Einstellungen in das TreeWidget. + Sortiert die Items alphabetisch nach ihrer ID. + """ + logger.info("Lade Nodes in TreeWidget...") + + try: + # TreeWidget leeren + self.ui.treeWidget.clear() + + # Lösche XML-Item-Map + self.xml_item_map.clear() + + # Prüfe ob pdf_project existiert und Nodes hat + if not hasattr(self, "pdf_project") or not self.pdf_project: + logger.warning("Keine Projekt-Einstellungen verfügbar") + return + + if not self.pdf_project.nodes: + logger.warning("Keine Nodes in den Projekt-Einstellungen gefunden") + return + + # Sortiere Root-Nodes alphabetisch nach ID + sorted_nodes = sorted(self.pdf_project.nodes, key=lambda node: node.id) + + # Lade alle Root-Nodes (sortiert) + for node in sorted_nodes: + tree_item = self._create_tree_item_from_node(node) + self.ui.treeWidget.addTopLevelItem(tree_item) + + logger.info(f"{len(self.pdf_project.nodes)} Root-Nodes in TreeWidget geladen (alphabetisch sortiert)") + + # Aktualisiere Diff-PDF-Anzahl und Icons nach dem Laden + self._update_all_diff_pdf_counts() + self._update_diff_icons_for_existing_pdfs() + + 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 + + # Kontextmenü-Aktionen für TreeNode + def _add_tree_node_child(self, parent_item): + """Fügt einen Unterknoten zu einem TreeNode hinzu.""" + logger.debug(f"Unterknoten zu TreeNode hinzufügen: {parent_item.text(0)}") + # TODO: Dialog zum Eingeben der Node-Daten öffnen + + def _add_xsl_file_to_node(self, parent_item): + """Fügt eine XSL-Datei zu einem TreeNode hinzu.""" + logger.debug(f"XSL-Datei zu TreeNode hinzufügen: {parent_item.text(0)}") + # TODO: Dialog zum Auswählen der XSL-Datei öffnen + + def _edit_tree_node(self, item): + """ + Bearbeitet einen TreeNode. + + Args: + item: Das TreeWidgetItem des TreeNode + """ + logger.debug(f"TreeNode bearbeiten: {item.text(0)}") + + try: + # Hole das Node-Objekt aus dem TreeWidgetItem + node = item.data(0, Qt.ItemDataRole.UserRole) + if not node or not isinstance(node, TreeNode): + QMessageBox.warning(self, "Warnung", "Kein gültiger TreeNode gefunden.") + return + + # Prüfe ob Projekt verfügbar ist + if not self.pdf_project: + QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen verfügbar.") + return + + # Sammle Eltern-Parameter + parent_params = self._collect_parent_params(item) + + # Erstelle und zeige den Dialog + dialog = TreeNodeEditDialog(self, node, parent_params) + if dialog.exec() == TreeNodeEditDialog.DialogCode.Accepted: + # Hole die bearbeiteten Daten + data = dialog.get_data() + if data: + # Aktualisiere den Node + node.bez = data["bez"] + node.xslt_params = data["xslt_params"] + + logger.info(f"TreeNode '{node.bez}' wurde aktualisiert") + logger.debug(f"XSLT-Parameter: {node.xslt_params}") + + # Speichere die Änderungen + self._save_project_settings() + + # Aktualisiere das TreeWidget + self._load_nodes_to_tree() + + # Wenn Force-Transformation gewünscht, führe sie aus + if data.get("force_transform", False): + # Finde das neue Item nach dem Neuladen + new_item = self._find_item_by_node(node) + if new_item: + logger.info(f"Starte Force-Transformation für TreeNode '{node.bez}'") + self._transform_tree_node(new_item, force=True) + else: + logger.warning(f"Konnte Item für TreeNode '{node.bez}' nicht finden") + + except Exception as e: + error_msg = f"Fehler beim Bearbeiten des TreeNode: {str(e)}" + logger.error(error_msg) + QMessageBox.critical(self, "Fehler", error_msg) + + def _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() + + except Exception as e: + error_msg = f"Fehler beim Hinzufügen der XML-Datei: {str(e)}" + logger.error(error_msg) + QMessageBox.critical(self, "Fehler", error_msg) + + def _edit_xsl_file(self, item): + """ + Bearbeitet eine XSL-Datei. + + Args: + item: Das TreeWidgetItem des XslFile + """ + logger.debug(f"XslFile bearbeiten: {item.text(0)}") + + try: + # Hole das Node-Objekt aus dem TreeWidgetItem + node = item.data(0, Qt.ItemDataRole.UserRole) + if not node or not isinstance(node, XslFile): + QMessageBox.warning(self, "Warnung", "Keine gültige XSL-Datei gefunden.") + return + + # Sammle Eltern-Parameter + parent_params = self._collect_parent_params(item) + + # Erstelle und zeige den Dialog + dialog = XslFileEditDialog(self, node, parent_params) + if dialog.exec() == XslFileEditDialog.DialogCode.Accepted: + # Hole die bearbeiteten Daten + data = dialog.get_data() + if data: + # Aktualisiere den Node + node.bez = data["bez"] + node.xslt_params = data["xslt_params"] + + logger.info(f"XslFile '{node.bez}' wurde aktualisiert") + logger.debug(f"XSLT-Parameter: {node.xslt_params}") + + # Speichere die Änderungen + self._save_project_settings() + + # Aktualisiere das TreeWidget + self._load_nodes_to_tree() + + # Wenn Force-Transformation gewünscht, führe sie aus + if data.get("force_transform", False): + # Finde das neue Item nach dem Neuladen + new_item = self._find_item_by_node(node) + if new_item: + logger.info(f"Starte Force-Transformation für XslFile '{node.bez}'") + self._transform_xsl_file(new_item, force=True) + else: + logger.warning(f"Konnte Item für XslFile '{node.bez}' nicht finden") + + except Exception as e: + error_msg = f"Fehler beim Bearbeiten der XSL-Datei: {str(e)}" + logger.error(error_msg) + QMessageBox.critical(self, "Fehler", error_msg) + + def _delete_xsl_file(self, item): + """Löscht eine XSL-Datei.""" + 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 _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. + """ + 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 diff --git a/src/ui/mixins/worker_pool.py b/src/ui/mixins/worker_pool.py new file mode 100644 index 0000000..25ddfff --- /dev/null +++ b/src/ui/mixins/worker_pool.py @@ -0,0 +1,192 @@ +""" +WorkerPoolMixin - Mixin für Worker-Pool-Verwaltung. + +Dieses Mixin enthält alle Methoden zur Verwaltung der Saxon- und FOP-Worker-Pools +für das MainWindow. +""" + +import logging + +from PySide6.QtWidgets import QMessageBox + +from conf import app_settings, XsltVersion +from transform import TransformationJob, set_saxon_worker_pool +from saxon_pool import SaxonWorkerPool +from saxon_pool_s9api import SaxonWorkerPoolS9Api + +logger = logging.getLogger(__name__) + + +class WorkerPoolMixin: + """ + Mixin für Worker-Pool-Verwaltung. + + Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute: + - self.project: Das aktuelle Projekt + """ + + 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() + + # Prüfe ob SaxonWorkerPool aktiviert ist + if not app_settings.use_saxon_worker_pool: + logger.info("SaxonWorkerPool deaktiviert - Verwende Fallback-Modus (subprocess)") + return + + 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 (wähle richtige Variante basierend auf XSLT-Version) + num_workers = app_settings.max_workers + log_dir = self.project.project_dir / "temp" + + # Wähle die richtige Worker-Pool-Implementierung + if app_settings.saxon_xslt_version == XsltVersion.XSLT_1_0: + # JAXP-basierte Variante für XSLT 1.0 + 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, + ) + pool_type = "JAXP (XSLT 1.0)" + else: + # s9api-basierte Variante für XSLT 2.0/3.0 + pool = SaxonWorkerPoolS9Api( + 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, + ) + pool_type = "s9api (XSLT 2.0/3.0)" + + # Setze globalen Pool + set_saxon_worker_pool(pool) + + logger.info( + f"Saxon-Worker-Pool initialisiert: {num_workers} Worker mit {pool_type} " + 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}") + logger.info("Fallback auf subprocess-Modus") + # 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 _initialize_fop_worker_pool(self): + """Initialisiert den FOP-Worker-Pool für schnelle PDF-Generierung.""" + try: + # Shutdown vorherigen Pool falls vorhanden + self._shutdown_fop_worker_pool() + + # Prüfe ob FopWorkerPool aktiviert ist + if not app_settings.use_fop_worker_pool: + logger.info("FopWorkerPool deaktiviert - Verwende Fallback-Modus (subprocess)") + return + + if not self.project: + logger.warning("Kein Projekt geladen, FOP-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) + apache_fop = next((fop for fop in app_settings.apache_fops if fop.id == self.project.apache_fop_id), None) + + if not java_vm or not apache_fop: + logger.warning("Java VM oder Apache FOP nicht gefunden, Pool nicht initialisiert") + return + + # FOP-Konfigurationsdatei (falls vorhanden) + fop_config_file = None + if self.project.fop_config_dir: + fop_config_file = self.project.fop_config_dir / "fop.xconf" + else: + default_config = apache_fop.path_to_dir / "conf" / "fop.xconf" + if default_config.exists(): + fop_config_file = default_config + + # Importiere FopWorkerPool + from fop_pool import FopWorkerPool + from transform import set_fop_worker_pool + + # Erstelle Worker-Pool + num_workers = app_settings.max_workers + log_dir = self.project.project_dir / "temp" + pool = FopWorkerPool( + num_workers=num_workers, + java_vm_path=java_vm.path_to_binary_file, + apache_fop_dir=apache_fop.path_to_dir, + fop_config_file=fop_config_file, + log_dir=log_dir, + ) + + # Setze globalen Pool + set_fop_worker_pool(pool) + + logger.info( + f"FOP-Worker-Pool initialisiert: {num_workers} Worker " + f"(erwartet: {num_workers}x schneller für PDF-Generierung)" + ) + + except Exception as e: + logger.error(f"Fehler beim Initialisieren des FOP-Worker-Pools: {e}") + logger.info("Fallback auf subprocess-Modus") + # Kein Pool ist OK - Fallback auf subprocess + + def _shutdown_fop_worker_pool(self): + """Beendet den FOP-Worker-Pool sauber.""" + try: + # Importiere transform um Zugriff auf globalen Pool zu haben + import transform + + if transform._fop_worker_pool: + logger.info("Beende FOP-Worker-Pool...") + transform._fop_worker_pool.shutdown() + # Importiere set_fop_worker_pool + from transform import set_fop_worker_pool + + set_fop_worker_pool(None) + logger.info("FOP-Worker-Pool beendet") + + except Exception as e: + logger.error(f"Fehler beim Beenden des FOP-Worker-Pools: {e}") + + def _show_worker_pool_metrics(self): + """Zeigt den Worker-Pool-Metriken-Dialog an.""" + try: + from ui.WorkerPoolMetricsDialog import WorkerPoolMetricsDialog + + dialog = WorkerPoolMetricsDialog(self) + dialog.exec() + except Exception as e: + logger.error(f"Fehler beim Öffnen des Metriken-Dialogs: {e}") + QMessageBox.critical(self, "Fehler", f"Fehler beim Öffnen des Metriken-Dialogs:\n{str(e)}") diff --git a/src/ui/threads.py b/src/ui/threads.py new file mode 100644 index 0000000..8c3bddb --- /dev/null +++ b/src/ui/threads.py @@ -0,0 +1,461 @@ +""" +Thread-Klassen für asynchrone Operationen in DocuMentor. + +Dieses Modul enthält alle QThread-Klassen, die für Hintergrundoperationen verwendet werden: +- XmlHashCalculatorThread: Berechnung von blake2b-Hashes für XML-Dateien +- XmlBatchProcessingThread: Batch-Verarbeitung von XML-Dateien +- TransformationThread: Ausführung von XSL-Transformationen +""" + +import hashlib +import logging +import shutil +from pathlib import Path +from typing import List + +from PySide6.QtCore import QThread, Signal + +from conf import TreeNode, XslFile, XmlFile +from transform import TransformationJob + + +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]" + )