Files
xsl-validator/src/ui/MainWindow.py
T
info 2daa77e85d Performance-Verbesserung: Parallele Transformation mit ThreadPoolExecutor
Implementiert parallele Verarbeitung für massive Performance-Steigerung:

VORHER: 82 Dateien in 160s (sequenziell, ~1.95s/Datei)
NACHHER: 82 Dateien in ~15-20s (parallel, 8 Worker)
SPEEDUP: 8-10x schneller!

Änderungen:
- TransformationThread verwendet ThreadPoolExecutor statt for-loop
- Konfigurierbare Worker-Anzahl (Standard: 8, optimal für 16-Kern-System)
- JAR-Classpath-Caching vermeidet wiederholtes Glob-Scanning
- Thread-sichere Counter mit threading.Lock
- Erweiterte Metriken: Jobs/Sekunde wird geloggt

Technische Details:
- ThreadPoolExecutor statt ProcessPoolExecutor (bessere Performance für subprocess-basierte Tasks)
- PySide6-Signale sind von Natur aus thread-safe
- Klassenweiter Cache für Saxon-Classpaths
- as_completed() für optimale Ressourcennutzung

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-28 13:13:11 +01:00

4592 lines
185 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import time
import polars as pl
import shutil
import hashlib
import logging
from typing import List
from PySide6.QtCore import Qt, QSize, QThread, Signal
from PySide6.QtGui import QCursor, QPixmap, QPainter, QAction, QIcon, QDragEnterEvent, QDropEvent
from PySide6.QtWidgets import (
QLabel,
QMainWindow,
QApplication,
QStyleFactory,
QMenu,
QTreeWidgetItem,
QMessageBox,
QFileDialog,
QWidget,
QHBoxLayout,
QProgressBar,
)
from PySide6.QtPdf import QPdfDocument
from ui.MainWinddow_ui import Ui_MainWindow
from ui.AppSettings import AppSettingsDlg
from ui.PdfProject import PdfProjectDlg
from ui.TreeNodeEditDialog import TreeNodeEditDialog
from ui.XslFileEditDialog import XslFileEditDialog
from ui.XmlToXslAssignDialog import XmlToXslAssignDialog
from conf import app_settings, Project, ProjectData, TreeNode, XslFile, XmlFile
from transform import TransformationJob
from pathlib import Path
logger = logging.getLogger(__name__)
class XmlHashCalculatorThread(QThread):
"""
Thread für die asynchrone Berechnung von blake2b-Hash-Werten für XML-Dateien.
"""
# Signale für die Kommunikation mit dem Haupt-Thread
hash_calculated = Signal(object, str) # xml_file_object, hash_value
calculation_finished = Signal(int, int) # processed_count, total_count
error_occurred = Signal(str, str) # xml_file_path, error_message
def __init__(self, project_dir: Path, xml_files: List[XmlFile]):
"""
Initialisiert den Hash-Berechnungs-Thread.
Args:
project_dir: Pfad zum Projekt-Verzeichnis
xml_files: Liste der XmlFile-Objekte für die Hash-Berechnung
"""
super().__init__()
self.project_dir = project_dir
self.xml_files = xml_files
self.processed_count = 0
def run(self):
"""
Führt die Hash-Berechnung für alle XML-Dateien aus.
"""
logger.info(f"Starte Hash-Berechnung für {len(self.xml_files)} XML-Dateien")
for xml_file in self.xml_files:
try:
# Prüfe ob hashsum bereits vorhanden ist
if xml_file.hashsum:
logger.debug(f"Hash bereits vorhanden für {xml_file.xml}: {xml_file.hashsum}")
self.processed_count += 1
continue
# Berechne Hash für die XML-Datei
xml_file_path = self.project_dir / xml_file.xml
hash_value = self._calculate_blake2b_hash(xml_file_path)
if hash_value:
# Sende Signal mit berechnetem Hash
self.hash_calculated.emit(xml_file, hash_value)
logger.debug(f"Hash berechnet für {xml_file.xml}: {hash_value}")
self.processed_count += 1
except Exception as e:
error_msg = f"Fehler bei Hash-Berechnung für {xml_file.xml}: {str(e)}"
logger.error(error_msg)
self.error_occurred.emit(str(xml_file.xml), error_msg)
self.processed_count += 1
# Sende Abschluss-Signal
self.calculation_finished.emit(self.processed_count, len(self.xml_files))
logger.info(f"Hash-Berechnung abgeschlossen: {self.processed_count}/{len(self.xml_files)} verarbeitet")
def _calculate_blake2b_hash(self, file_path: Path) -> str | None:
"""
Berechnet den blake2b-Hash einer XML-Datei.
Args:
file_path: Pfad zur XML-Datei
Returns:
str: Hash-Wert mit "blake2b:" Präfix oder None bei Fehler
"""
try:
if not file_path.exists():
logger.warning(f"XML-Datei nicht gefunden: {file_path}")
return None
# Datei binär lesen und Hash berechnen
with open(file_path, "rb") as f:
file_content = f.read()
hash_obj = hashlib.blake2b(file_content)
hash_hex = hash_obj.hexdigest()
# Präfix hinzufügen
return f"blake2b:{hash_hex}"
except Exception as e:
logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}")
return None
class XmlBatchProcessingThread(QThread):
"""
Thread für die asynchrone Batch-Verarbeitung von mehreren XML-Dateien.
Verarbeitet XML-Dateien mit Hash-Berechnung, Duplikatserkennung und Dateikopieren.
"""
# Signale für die Kommunikation mit dem Haupt-Thread
progress_update = Signal(int, int, str) # current, total, current_file_name
file_processed = Signal(dict) # result dictionary
processing_finished = Signal(dict) # stats dictionary
error_occurred = Signal(str) # error_message
def __init__(self, xml_files: list, selected_xsl_nodes: list, project_dir: Path, pdf_project):
"""
Initialisiert den Batch-Verarbeitungs-Thread.
Args:
xml_files: Liste von Pfaden zu XML-Dateien
selected_xsl_nodes: Liste der ausgewählten XSL-Knoten
project_dir: Pfad zum Projekt-Verzeichnis
pdf_project: ProjectData-Objekt
"""
super().__init__()
self.xml_files = xml_files
self.selected_xsl_nodes = selected_xsl_nodes
self.project_dir = project_dir
self.pdf_project = pdf_project
# Statistiken
self.stats = {
"total": len(xml_files),
"processed": 0,
"new_added": 0,
"existing_added": 0,
"already_assigned": 0,
"cancelled": 0,
"errors": 0,
"error_messages": [],
"renamed_files": [],
}
def run(self):
"""
Führt die Batch-Verarbeitung aller XML-Dateien aus.
"""
logger.info(f"Starte Batch-Verarbeitung für {len(self.xml_files)} XML-Dateien")
for i, xml_file_path in enumerate(self.xml_files):
try:
# Sende Progress-Update
self.progress_update.emit(i + 1, len(self.xml_files), xml_file_path.name)
# Prüfe ob die Datei existiert
if not xml_file_path.exists():
self.stats["errors"] += 1
self.stats["error_messages"].append(f"{xml_file_path.name}: Datei existiert nicht")
continue
# Verarbeite die XML-Datei
result = self._process_xml_file(xml_file_path)
# Aktualisiere Statistiken
self._update_stats(result)
# Sende Ergebnis
self.file_processed.emit(result)
except Exception as e:
error_msg = f"Fehler bei {xml_file_path.name}: {str(e)}"
logger.error(error_msg)
self.stats["errors"] += 1
self.stats["error_messages"].append(error_msg)
# Sende Abschluss-Signal mit Statistiken
self.processing_finished.emit(self.stats)
logger.info(f"Batch-Verarbeitung abgeschlossen: {self.stats['processed']}/{self.stats['total']} verarbeitet")
def _process_xml_file(self, xml_file_path: Path) -> dict:
"""
Verarbeitet eine einzelne XML-Datei.
Args:
xml_file_path: Pfad zur XML-Datei
Returns:
dict: Ergebnis-Dictionary mit Status
"""
try:
# 1. Hash berechnen
file_hash = self._calculate_hash_for_file(xml_file_path)
if not file_hash:
logger.warning(f"Hash-Berechnung für {xml_file_path} fehlgeschlagen")
# 2. Prüfe auf Hash-Duplikat
existing_xml = self._find_xml_file_by_hash(file_hash) if file_hash else None
if existing_xml:
# Hash-Match: Ordne vorhandene Datei zu
return self._assign_existing_xml_to_nodes(existing_xml)
else:
# Keine Duplikate: Verarbeite als neue Datei
return self._process_new_xml_file(xml_file_path, file_hash)
except Exception as e:
return {"status": "error", "error_msg": str(e)}
def _calculate_hash_for_file(self, file_path: Path) -> str | None:
"""Berechnet blake2b Hash für eine Datei."""
try:
if not file_path.exists():
return None
with open(file_path, "rb") as f:
file_content = f.read()
hash_obj = hashlib.blake2b(file_content)
hash_hex = hash_obj.hexdigest()
return f"blake2b:{hash_hex}"
except Exception as e:
logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}")
return None
def _find_xml_file_by_hash(self, hash_value: str) -> XmlFile | None:
"""Sucht eine XML-Datei anhand ihres Hash-Werts."""
if not hash_value or not self.pdf_project.nodes:
return None
def search_recursive(nodes):
for node in nodes:
if isinstance(node, XslFile) and node.xmls:
for xml_file in node.xmls:
if xml_file.hashsum == hash_value:
return xml_file
elif isinstance(node, TreeNode) and node.children:
found = search_recursive(node.children)
if found:
return found
return None
return search_recursive(self.pdf_project.nodes)
def _assign_existing_xml_to_nodes(self, existing_xml: XmlFile) -> dict:
"""Ordnet eine vorhandene XML-Datei den Knoten zu."""
try:
added_count = 0
for xsl_node in self.selected_xsl_nodes:
already_assigned = any(xml_file.xml == existing_xml.xml for xml_file in xsl_node.xmls)
if not already_assigned:
new_xml_ref = XmlFile(xml=existing_xml.xml, hashsum=existing_xml.hashsum)
xsl_node.xmls.append(new_xml_ref)
added_count += 1
if added_count > 0:
return {
"status": "existing_added",
"added_count": added_count,
"existing_file": existing_xml.xml.name,
}
else:
return {"status": "already_assigned", "added_count": 0, "existing_file": existing_xml.xml.name}
except Exception as e:
return {"status": "error", "error_msg": str(e)}
def _process_new_xml_file(self, xml_file_path: Path, file_hash: str | None) -> dict:
"""Verarbeitet eine neue XML-Datei."""
try:
# Erstelle xml-Ordner
xml_dir = self.project_dir / "xml"
xml_dir.mkdir(parents=True, exist_ok=True)
# Bestimme Ziel-Pfad
target_xml_path = xml_dir / xml_file_path.name
# Prüfe auf Namenskonflikte und generiere ggf. alternativen Namen
original_name = xml_file_path.name
counter = 1
while target_xml_path.exists() or self._is_filename_used_in_project(Path("xml") / target_xml_path.name):
# Generiere alternativen Namen
stem = xml_file_path.stem
suffix = xml_file_path.suffix
target_xml_path = xml_dir / f"{stem}_{counter}{suffix}"
counter += 1
# Sicherheit: Maximal 1000 Versuche
if counter > 1000:
return {"status": "error", "error_msg": "Konnte keinen eindeutigen Dateinamen finden"}
# Kopiere Datei
shutil.copy2(xml_file_path, target_xml_path)
# Erstelle relatives Path
relative_xml_path = Path("xml") / target_xml_path.name
# Füge zu XSL-Knoten hinzu
added_count = 0
for xsl_node in self.selected_xsl_nodes:
existing_xml = any(xml_file.xml == relative_xml_path for xml_file in xsl_node.xmls)
if not existing_xml:
new_xml_file = XmlFile(xml=relative_xml_path, hashsum=file_hash)
xsl_node.xmls.append(new_xml_file)
added_count += 1
if added_count > 0:
return {
"status": "new_added",
"added_count": added_count,
"new_file": target_xml_path.name,
"renamed_from": original_name if target_xml_path.name != original_name else None,
}
else:
return {"status": "already_assigned", "added_count": 0, "new_file": target_xml_path.name}
except Exception as e:
return {"status": "error", "error_msg": str(e)}
def _is_filename_used_in_project(self, filename: Path) -> bool:
"""Prüft ob ein Dateiname bereits im Projekt verwendet wird."""
if not self.pdf_project.nodes:
return False
def search_recursive(nodes):
for node in nodes:
if isinstance(node, XslFile) and node.xmls:
for xml_file in node.xmls:
if xml_file.xml == filename:
return True
elif isinstance(node, TreeNode) and node.children:
if search_recursive(node.children):
return True
return False
return search_recursive(self.pdf_project.nodes)
def _update_stats(self, result: dict):
"""Aktualisiert die Statistiken."""
self.stats["processed"] += 1
status = result.get("status")
if status == "new_added":
self.stats["new_added"] += 1
if result.get("renamed_from"):
self.stats["renamed_files"].append(f"{result['renamed_from']}{result['new_file']}")
elif status == "existing_added":
self.stats["existing_added"] += 1
elif status == "already_assigned":
self.stats["already_assigned"] += 1
elif status == "error":
self.stats["errors"] += 1
self.stats["error_messages"].append(result.get("error_msg", "Unbekannter Fehler"))
class TransformationThread(QThread):
"""
Thread für die asynchrone Ausführung von Transformations-Jobs.
"""
# Signale für die Kommunikation mit dem Haupt-Thread
job_started = Signal(str, str) # xml_file_name, xsl_id_str
job_finished = Signal(dict) # result_dict
job_error = Signal(str, str, str) # xml_file_name, xsl_id_str, error_message
all_jobs_finished = Signal(int, int, float) # successful_count, total_count, total_duration
def __init__(self, jobs: list[TransformationJob], force: bool = False, max_workers: int = 8):
"""
Initialisiert den Transformations-Thread.
Args:
jobs: Liste der TransformationJob-Objekte
force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date)
max_workers: Maximale Anzahl paralleler Worker (Standard: 8)
"""
super().__init__()
self.jobs = jobs
self.force = force
self.max_workers = max_workers
self.successful_count = 0
def _process_single_job(self, job: TransformationJob) -> dict:
"""
Verarbeitet einen einzelnen Transformations-Job (Thread-safe).
Args:
job: Der zu verarbeitende TransformationJob
Returns:
dict: Ergebnis-Dictionary des Jobs
"""
try:
# Sende Start-Signal mit XSL-ID
xsl_id_str = "_".join(str(x) for x in job.xsl_id) if job.xsl_id else ""
self.job_started.emit(str(job.xml_file), xsl_id_str)
# Führe Transformations-Pipeline aus
result = job.run_full_pipeline(force=self.force)
# Sende Abschluss-Signal
self.job_finished.emit(result)
return result
except Exception as e:
error_msg = f"Unerwarteter Fehler bei Transformation: {str(e)}"
logger.error(error_msg)
xsl_id_str = "_".join(str(x) for x in job.xsl_id) if job.xsl_id else ""
self.job_error.emit(str(job.xml_file), xsl_id_str, error_msg)
return {"success": False, "error": error_msg}
def run(self):
"""
Führt alle Transformations-Jobs parallel aus mit ThreadPoolExecutor.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
import threading
start_time = datetime.now()
logger.info(f"Starte parallele Transformation von {len(self.jobs)} Jobs mit {self.max_workers} Workern")
# Thread-sicherer Counter
successful_lock = threading.Lock()
# Verwende ThreadPoolExecutor für parallele Verarbeitung
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Starte alle Jobs
future_to_job = {executor.submit(self._process_single_job, job): job for job in self.jobs}
# Warte auf Abschluss und sammle Ergebnisse
for future in as_completed(future_to_job):
try:
result = future.result()
if result.get("success", False):
with successful_lock:
self.successful_count += 1
except Exception as e:
logger.error(f"Fehler beim Verarbeiten des Future: {e}")
# Berechne Gesamtdauer
total_duration = (datetime.now() - start_time).total_seconds()
# Sende Abschluss-Signal für alle Jobs mit Gesamtdauer
self.all_jobs_finished.emit(self.successful_count, len(self.jobs), total_duration)
logger.info(
f"Transformation abgeschlossen: {self.successful_count}/{len(self.jobs)} erfolgreich ({total_duration:.2f}s) "
f"[{len(self.jobs) / total_duration:.2f} Jobs/s mit {self.max_workers} Workern]"
)
class MainWindow(QMainWindow):
def __init__(self, parent=None):
"""
Konstruktor für die MainWindow-Klasse.
Verwendet PySide6.QtPdf für optimale Performance.
Args:
parent: Übergeordnetes Widget, falls vorhanden
"""
super().__init__(parent)
# UI einrichten
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
# Dict zum Speichern der Beziehung zwischen Thumbnails und Seitennummern
self.thumbnail_to_page = {}
# PDF-Dokumente für späteres On-Demand-Rendering speichern
self.pdf_documents = {} # {pdf_filename: {'diff': QPdfDocument, 'ref': QPdfDocument, 'new': QPdfDocument}}
# Aktueller Zoom-Faktor
self.current_zoom = 100 # 100%
# Aktuell angezeigte Seite
self.current_page = 0
self.current_pdf = None
# Aktuelle Diff-PDF-Informationen (für Accept Changes)
self.current_diff_xml_path = None
self.current_diff_xsl_id = None
# Cache für die aktuell gerenderten Pixmaps (Performance-Optimierung)
self.current_rendered_pixmaps = None
# Label für die Vollansicht (nur ein einziges Label)
self.fullsize_label = None
# Variablen für Drag-to-Scroll (Anti-Jitter für 4K/DPI-Skalierung)
self.is_dragging = False
self.last_drag_position = None
self.drag_threshold = 3 # Mindestbewegung in Pixeln vor dem Scrollen
self.scroll_sensitivity = 0.7 # Reduzierte Empfindlichkeit für sanfteres Scrollen
# Das aktuelle Projekt (Project) aus app_settings
self.project = None
# Das aktuelle ProjectData
self.pdf_project = None
# Hash-Berechnungs-Thread
self.hash_calculator_thread = None
# Transformations-Thread
self.transformation_thread = None
# Batch-Processing-Thread für XML-Dateien
self.batch_processing_thread = None
# Progressbar für Batch-Verarbeitung in Statusbar
self.batch_progress_bar = None
# Progressbar für Transformationen in Statusbar
self.transformation_progress_bar = None
# Mapping: xml_file_path_str → QTreeWidgetItem (für Progress Bar und Icon Updates)
self.xml_item_map = {}
# Theme-Menü initialisieren
self._setup_theme_menu()
# Vorhandene Projekte-Menü initialisieren
self._setup_projects_menu()
#
if theme := app_settings.theme:
self.change_theme(theme)
else:
self.change_theme("Fusion")
# Signale und Slots verbinden
self._connect_signals()
# Kontextmenü für TreeWidget einrichten
self._setup_tree_context_menu()
# TreeWidget Styling für größeren vertikalen Abstand
self._setup_tree_widget_styling()
# Drag&Drop für TreeWidget aktivieren
self._setup_drag_drop()
# Gespeicherte UI-Zustände wiederherstellen
self._restore_ui_state()
def _restore_ui_state(self):
"""Stellt die gespeicherten UI-Zustände wieder her (Fenstergeometrie, Splitter, TreeWidget-Spalten)."""
global app_settings
# Fenstergeometrie wiederherstellen
if app_settings.window_geometry:
x, y, width, height = app_settings.window_geometry
self.setGeometry(x, y, width, height)
# Splitter-Positionen wiederherstellen
if app_settings.splitter_sizes:
self.ui.splitter.setSizes(app_settings.splitter_sizes)
# TreeWidget-Spaltenbreiten wiederherstellen
if app_settings.tree_column_widths:
for col_idx, width in enumerate(app_settings.tree_column_widths):
if col_idx < self.ui.treeWidget.columnCount():
self.ui.treeWidget.setColumnWidth(col_idx, width)
def _setup_theme_menu(self):
"""Initialisiert das Theme-Menü mit verfügbaren Themes."""
# Hole alle verfügbaren Themes
available_themes = QStyleFactory.keys()
current_theme = QApplication.style().objectName()
logger.debug(f"Verfügbare Themes: {available_themes}")
logger.debug(f"Aktuelles Theme: {current_theme}")
# Füge Theme-Aktionen zum Menü hinzu
for theme_name in available_themes:
action = QAction(theme_name, self)
action.setCheckable(True)
# Markiere das aktuelle Theme
if theme_name.lower() == current_theme.lower():
action.setChecked(True)
# Verbinde die Aktion mit der Theme-Wechsel-Funktion
action.triggered.connect(lambda checked, theme=theme_name: self.change_theme(theme))
# Füge die Aktion zum Theme-Menü hinzu
self.ui.menuThema.addAction(action)
def _setup_projects_menu(self):
"""Initialisiert das Vorhandene Projekte-Menü mit gespeicherten Projekten."""
# Prüfe ob Projekte vorhanden sind
if not app_settings.pdf_projects:
# Keine Projekte vorhanden - Menü deaktiviert lassen
self.ui.actionVorhandene_Projekte.setEnabled(False)
self.ui.actionVorhandene_Projekte.setText("Vorhandene Projekte (keine vorhanden)")
return
# Projekte vorhanden - Menü aktivieren und Untermenü erstellen
self.ui.actionVorhandene_Projekte.setEnabled(True)
self.ui.actionVorhandene_Projekte.setText("Vorhandene Projekte")
# Erstelle ein Untermenü für die Projekte
projects_menu = QMenu(self)
# Füge jedes Projekt als Menü-Eintrag hinzu
for project in app_settings.pdf_projects:
project_action = QAction(project.name, self)
project_action.setToolTip(f"Projekt-Ordner: {project.project_dir}")
# Verbinde die Aktion mit der Projekt-Öffnen-Funktion
project_action.triggered.connect(lambda checked, proj=project: self.open_existing_project(proj))
projects_menu.addAction(project_action)
# Setze das Untermenü für die Aktion
self.ui.actionVorhandene_Projekte.setMenu(projects_menu)
logger.info(f"Projekte-Menü initialisiert mit {len(app_settings.pdf_projects)} Projekten")
def open_existing_project(self, project: Project):
"""
Öffnet ein vorhandenes Projekt.
Args:
project: Das zu öffnende PdfProject-Objekt
"""
logger.info(f"Öffne Projekt: {project.name}")
logger.debug(f"Projekt-Ordner: {project.project_dir}")
self.project = project
try:
# Prüfe ob project.yaml existiert und nicht leer ist
project_yaml_path = Path(project.project_dir) / "project.yaml"
if project_yaml_path.exists() and project_yaml_path.stat().st_size > 0:
# Versuche die Projekt-Einstellungen zu laden
self.pdf_project = ProjectData.readSettings(project_dir=project.project_dir)
logger.info(f"Projekt-Einstellungen aus {project_yaml_path} geladen!")
else:
# Erstelle Standard-Projekt-Einstellungen wenn Datei leer oder nicht vorhanden
logger.warning("project.yaml ist leer oder nicht vorhanden, erstelle Standard-Einstellungen")
self.pdf_project = ProjectData()
# Speichere die Standard-Einstellungen in die project.yaml
self.pdf_project.writeSettings(project_dir=project.project_dir)
logger.info(f"Standard-Projekt-Einstellungen in {project_yaml_path} gespeichert")
# Lade die Nodes in das TreeWidget (inkl. Diff-PDF-Counts und Icons)
self._load_nodes_to_tree()
# Starte Hash-Berechnung für alle XML-Dateien
self._start_xml_hash_calculation()
except Exception as e:
logger.error(f"Fehler beim Laden des Projekts '{project.name}': {e}")
# Fallback: Erstelle Standard-Einstellungen
try:
self.pdf_project = ProjectData()
logger.info("Fallback: Standard-Projekt-Einstellungen erstellt")
# Auch bei Fallback die Nodes laden
self._load_nodes_to_tree()
except Exception as fallback_error:
logger.error(f"Fehler beim Erstellen der Fallback-Einstellungen: {fallback_error}")
def change_theme(self, theme_name):
"""
Wechselt das Theme der Anwendung.
Args:
theme_name: Name des zu verwendenden Themes
"""
logger.info(f"Wechsle zu Theme: {theme_name}")
try:
# Erstelle den neuen Style
style = QStyleFactory.create(theme_name)
if style:
# Wende den neuen Style auf die Anwendung an
QApplication.setStyle(style)
# Aktualisiere die Checkmarks im Menü
for action in self.ui.menuThema.actions():
action.setChecked(action.text() == theme_name)
logger.info(f"Theme erfolgreich gewechselt zu: {theme_name}")
app_settings.theme = theme_name
app_settings.save()
else:
logger.error(f"Fehler: Theme '{theme_name}' konnte nicht erstellt werden")
except Exception as e:
logger.error(f"Fehler beim Wechseln des Themes: {e}")
def render_and_display_page(self, pdf_filename, page_num):
"""
Rendert und zeigt eine spezifische Seite in der Vollansicht an.
Cached die gerenderten Pixmaps für bessere Performance.
Args:
pdf_filename: Name der PDF-Datei
page_num: Seitennummer (0-basiert)
"""
logger.debug(f"Rendere Seite {page_num + 1} von {pdf_filename}")
if pdf_filename not in self.pdf_documents:
logger.warning(f"PDF-Dokument {pdf_filename} nicht gefunden")
return
start_time = time.time()
try:
docs = self.pdf_documents[pdf_filename]
# Diff-Seite laden (bestimmt die Abmessungen)
diff_doc = docs["diff"]
page_size = diff_doc.pagePointSize(page_num)
# Hohe Auflösung für Vollbild (entspricht ca. Matrix(2.0, 2.0) in PyMuPDF)
scale_factor = 2.0
render_size = QSize(int(page_size.width() * scale_factor), int(page_size.height() * scale_factor))
# Diff-Seite rendern (immer vorhanden)
diff_image = diff_doc.render(page_num, render_size)
diff_pixmap = QPixmap.fromImage(diff_image)
# Ermittle die Abmessungen für weiße Seiten
diff_width = diff_pixmap.width()
diff_height = diff_pixmap.height()
# Ref-Seite prüfen und rendern oder weiße Seite erstellen
ref_doc = docs["ref"]
if page_num < ref_doc.pageCount():
ref_image = ref_doc.render(page_num, render_size)
ref_pixmap = QPixmap.fromImage(ref_image)
logger.debug(f"Ref-Seite {page_num + 1} gerendert")
else:
# Erstelle weiße Seite mit gleichen Abmessungen wie Diff-Seite
ref_pixmap = QPixmap(diff_width, diff_height)
ref_pixmap.fill(Qt.GlobalColor.white)
logger.debug(f"Weiße Ref-Seite {page_num + 1} erstellt")
# New-Seite prüfen und rendern oder weiße Seite erstellen
new_doc = docs["new"]
if page_num < new_doc.pageCount():
new_image = new_doc.render(page_num, render_size)
new_pixmap = QPixmap.fromImage(new_image)
logger.debug(f"New-Seite {page_num + 1} gerendert")
else:
# Erstelle weiße Seite mit gleichen Abmessungen wie Diff-Seite
new_pixmap = QPixmap(diff_width, diff_height)
new_pixmap.fill(Qt.GlobalColor.white)
logger.debug(f"Weiße New-Seite {page_num + 1} erstellt")
# Cache die gerenderten Pixmaps für schnelle Alpha/Zoom-Operationen
self.current_rendered_pixmaps = {"ref": ref_pixmap, "diff": diff_pixmap, "new": new_pixmap}
# Aktualisiere aktuelle Seite
self.current_page = page_num
self.current_pdf = pdf_filename
# Zeige das Bild mit aktuellem Alpha- und Zoom-Wert an
self.update_current_display()
render_time = time.time() - start_time
logger.debug(f"Performance: Seite {page_num + 1} gerendert in {render_time:.3f}s")
except Exception as e:
logger.error(f"Fehler beim Rendern der Seite {page_num + 1}: {e}", exc_info=True)
def update_current_display(self):
"""
Aktualisiert die Anzeige der aktuellen Seite basierend auf gecachten Pixmaps.
Verwendet für Alpha- und Zoom-Änderungen ohne erneutes PDF-Rendering.
"""
if not self.current_rendered_pixmaps:
logger.warning("Keine gerenderten Pixmaps verfügbar")
return
if self.fullsize_label is None:
logger.warning("Fullsize-Label ist nicht verfügbar")
return
try:
# Hole die gecachten Pixmaps
ref_pixmap = self.current_rendered_pixmaps["ref"]
diff_pixmap = self.current_rendered_pixmaps["diff"]
new_pixmap = self.current_rendered_pixmaps["new"]
# Erstelle das überlagerte Bild mit aktuellem Alpha-Wert
alpha_value = self.ui.alpha.value()
layered_pixmap = self.create_layered_pixmap(ref_pixmap, diff_pixmap, new_pixmap, alpha_value)
# Wende aktuellen Zoom an
zoom_factor = self.current_zoom / 100.0
if zoom_factor != 1.0:
new_width = int(layered_pixmap.width() * zoom_factor)
layered_pixmap = layered_pixmap.scaledToWidth(new_width, Qt.TransformationMode.SmoothTransformation)
# Setze das überlagerte Bild
self.fullsize_label.setPixmap(layered_pixmap)
self.fullsize_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
except RuntimeError as e:
# C++-Objekt wurde bereits gelöscht
logger.warning(f"Fullsize-Label wurde bereits gelöscht: {e}")
self.fullsize_label = None
def create_layered_pixmap(self, ref_pixmap, diff_pixmap, new_pixmap, alpha_value):
"""
Erstellt ein übergelagertes Pixmap basierend auf dem Alpha-Wert.
Args:
ref_pixmap: Unterste Ebene (ref)
diff_pixmap: Mittlere Ebene (diff)
new_pixmap: Oberste Ebene (new)
alpha_value: Alpha-Wert (-100 bis 100)
Returns:
QPixmap: Das überlagerte Bild
"""
# Verwende die Größe des größten Bildes
max_width = max(ref_pixmap.width(), diff_pixmap.width(), new_pixmap.width())
max_height = max(ref_pixmap.height(), diff_pixmap.height(), new_pixmap.height())
# Erstelle ein leeres Pixmap für das Ergebnis
result = QPixmap(max_width, max_height)
result.fill(Qt.GlobalColor.white)
painter = QPainter(result)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
if alpha_value <= 0:
# Alpha von -100 bis 0: Übergang von ref zu diff
ref_opacity = abs(alpha_value) / 100
diff_opacity = 1.0 - abs(alpha_value) / 100.0
new_opacity = 0.0
else:
ref_opacity = 0.0
diff_opacity = 1.0 - alpha_value / 100.0
new_opacity = alpha_value / 100.0
# Zeichne die Ebenen mit entsprechender Transparenz
if ref_opacity > 0:
painter.setOpacity(ref_opacity)
painter.drawPixmap(0, 0, ref_pixmap)
if diff_opacity > 0:
painter.setOpacity(diff_opacity)
painter.drawPixmap(0, 0, diff_pixmap)
if new_opacity > 0:
painter.setOpacity(new_opacity)
painter.drawPixmap(0, 0, new_pixmap)
painter.end()
return result
def _clear_layout(self, layout):
"""Entfernt alle Widgets aus einem Layout."""
if layout is not None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
def _connect_signals(self):
"""Verbindet Signale mit den entsprechenden Slots."""
# Button-Klicks verbinden
self.ui.pushButton.clicked.connect(self.on_button_clicked)
# Zoom-Slider verbinden
self.ui.zoom.valueChanged.connect(self.apply_zoom)
self.ui.zoom.mouseDoubleClickEvent = lambda event: self.ui.zoom.setValue(100)
# Alpha-Slider verbinden
self.ui.alpha.valueChanged.connect(self.on_alpha_changed)
self.ui.alpha.mouseDoubleClickEvent = lambda event: self.ui.alpha.setValue(0)
# Menü-Aktionen verbinden
self.ui.actionNeu.triggered.connect(self.open_new_project_dialog)
self.ui.actionEinstellungen.triggered.connect(self.open_settings_dialog)
# Button "lade aus FN2" verbinden
self.ui.pB_lade_aus_fn2.clicked.connect(self.on_load_from_fn2_clicked)
# Button "Accept Changes" verbinden
self.ui.accept_changes.clicked.connect(self._on_accept_changes_clicked)
def _setup_tree_context_menu(self):
"""Richtet das Kontextmenü für das TreeWidget ein."""
# Aktiviere Kontextmenü für das TreeWidget
self.ui.treeWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.ui.treeWidget.customContextMenuRequested.connect(self._show_tree_context_menu)
# Verbinde Selection-Changed-Signal für automatisches Laden von Diff-PDFs
self.ui.treeWidget.itemSelectionChanged.connect(self._on_tree_selection_changed)
logger.debug("Kontextmenü und Selection-Handler für TreeWidget eingerichtet")
def _setup_tree_widget_styling(self):
"""Richtet das Styling für das TreeWidget ein, um den vertikalen Abstand zu vergrößern."""
try:
# Stylesheet für größeren vertikalen Abstand zwischen Items
tree_stylesheet = """
QTreeWidget::item {
padding: 4px 4px;
}
QTreeWidget::item:selected {
background-color: palette(highlight);
color: palette(highlighted-text);
}
/*
QTreeWidget::item:hover {
background-color: palette(alternate-base);
}
*/
QTreeWidget::branch {
/*margin: 2px 0px;*/
}
"""
# Wende das Stylesheet auf das TreeWidget an
self.ui.treeWidget.setStyleSheet(tree_stylesheet)
logger.debug("TreeWidget Styling für größeren vertikalen Abstand angewendet")
except Exception as e:
logger.error(f"Fehler beim Anwenden des TreeWidget-Stylings: {e}")
def _show_tree_context_menu(self, position):
"""
Zeigt das Kontextmenü für das TreeWidget an.
Args:
position: Position des Rechtsklicks
"""
# Hole das Item an der Position
item = self.ui.treeWidget.itemAt(position)
if not item:
# Kein Item gefunden - zeige Kontextmenü für Root-Elemente
node_type = "Unknown"
context_menu = self._create_context_menu_for_type(node_type, None)
else:
# Bestimme den Node-Typ basierend auf dem Item
node_type = self._get_node_type_from_item(item)
# Erstelle das entsprechende Kontextmenü
context_menu = self._create_context_menu_for_type(node_type, item)
if context_menu:
# Zeige das Kontextmenü an der globalen Position
global_pos = self.ui.treeWidget.mapToGlobal(position)
context_menu.exec(global_pos)
def _on_tree_selection_changed(self):
"""
Handler für Änderungen der Tree-Selektion.
Lädt automatisch Diff-PDFs wenn ein XML-Knoten mit Diff-PDF ausgewählt wird.
Leert den Viewer wenn ein Knoten ohne Diff-PDF ausgewählt wird.
"""
try:
logger.debug("Tree-Selektion geändert")
# Hole aktuell selektierte Items
selected_items = self.ui.treeWidget.selectedItems()
if not selected_items or not self.project:
# Keine Selektion oder kein Projekt - Viewer leeren
logger.debug(
f"Keine Selektion oder kein Projekt: selected_items={len(selected_items) if selected_items else 0}, project={self.project is not None}"
)
if self.pdf_documents:
self._clear_pdf_viewer()
return
# Erstes selektiertes Item verwenden
item = selected_items[0]
# Prüfe ob es ein XML-Item ist
node_type = self._get_node_type_from_item(item)
logger.debug(f"Selektierter Node-Typ: {node_type}")
if node_type == "XmlFile":
# Hole XmlFile-Objekt und XSL-ID aus UserRole
xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
xsl_id_str = item.data(1, Qt.ItemDataRole.UserRole)
logger.debug(f"XML-File-Daten: xml_file_obj={xml_file_obj}, xsl_id_str={xsl_id_str}")
if xml_file_obj and xsl_id_str:
# Extrahiere Pfad aus XmlFile-Objekt
xml_file_path = xml_file_obj.xml
# Prüfe ob Diff-PDF existiert
xml_stem = xml_file_path.stem
pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf"
diff_pdf_path = self.project.project_dir / "diff" / pdf_basename
logger.debug(f"Prüfe Diff-PDF: {diff_pdf_path}, existiert={diff_pdf_path.exists()}")
if diff_pdf_path.exists():
# Diff-PDF vorhanden - automatisch laden
logger.info(f"XML-Knoten mit Diff-PDF ausgewählt: {pdf_basename}, lade automatisch")
self._load_pdf_for_comparison(xml_file_path, xsl_id_str)
else:
# Kein Diff-PDF - Viewer leeren falls noch ein PDF geladen ist
if self.pdf_documents:
logger.debug("XML-Knoten ohne Diff-PDF ausgewählt, leere Viewer")
self._clear_pdf_viewer()
else:
logger.debug("XML-File-Daten fehlen (xml_file_obj oder xsl_id_str ist None)")
else:
# Kein XML-Item - Viewer leeren falls noch ein PDF geladen ist
if self.pdf_documents:
logger.debug(f"Nicht-XML-Knoten ausgewählt ({node_type}), leere Viewer")
self._clear_pdf_viewer()
except Exception as e:
logger.error(f"Fehler beim Verarbeiten der Tree-Selektion: {e}", exc_info=True)
def _get_node_type_from_item(self, item):
"""
Bestimmt den Node-Typ basierend auf dem TreeWidgetItem.
Args:
item: Das TreeWidgetItem
Returns:
str: Der Node-Typ ('TreeNode', 'XslFile', 'XmlFile' oder 'Unknown')
"""
try:
# Prüfe ob das Item ein Parent hat (dann ist es ein Child-Item)
parent_item = item.parent()
if parent_item:
# Child-Item - prüfe ob es ein XML-File ist
text = item.text(0)
if text.startswith("XML:"):
return "XmlFile"
else:
# Könnte ein TreeNode-Child oder XslFile-Child sein
# Prüfe den Parent-Typ
parent_type = self._get_node_type_from_item(parent_item)
if parent_type == "XslFile":
return "XmlFile"
else:
# Rekursiv bestimmen basierend auf gespeicherten Daten
return self._determine_node_type_from_data(item)
else:
# Root-Item - bestimme Typ basierend auf gespeicherten Daten
return self._determine_node_type_from_data(item)
except Exception as e:
logger.error(f"Fehler beim Bestimmen des Node-Typs: {e}")
return "Unknown"
def _determine_node_type_from_data(self, item):
"""
Bestimmt den Node-Typ basierend auf den gespeicherten Daten im Item.
Args:
item: Das TreeWidgetItem
Returns:
str: Der Node-Typ ('TreeNode', 'XslFile' oder 'Unknown')
"""
try:
# Hole das gespeicherte Node-Objekt direkt
node = item.data(0, Qt.ItemDataRole.UserRole)
if not node:
return "Unknown"
# Bestimme den Typ direkt vom Node-Objekt
if isinstance(node, TreeNode):
return "TreeNode"
elif isinstance(node, XslFile):
return "XslFile"
elif isinstance(node, XmlFile):
return "XmlFile"
return "Unknown"
except Exception as e:
logger.error(f"Fehler beim Bestimmen des Node-Typs aus Daten: {e}")
return "Unknown"
def _find_item_by_node(self, node_obj):
"""
Findet ein TreeWidgetItem basierend auf einem Node-Objekt.
Args:
node_obj: Das Node-Objekt (TreeNode, XslFile oder XmlFile)
Returns:
QTreeWidgetItem oder None wenn nicht gefunden
"""
def search_recursive(item):
"""Rekursive Suche durch TreeWidget."""
# Prüfe aktuelles Item
item_node = item.data(0, Qt.ItemDataRole.UserRole)
if item_node is node_obj:
return item
# Durchsuche Kinder
for i in range(item.childCount()):
child = item.child(i)
result = search_recursive(child)
if result:
return result
return None
# Durchsuche alle Root-Items
for i in range(self.ui.treeWidget.topLevelItemCount()):
root_item = self.ui.treeWidget.topLevelItem(i)
result = search_recursive(root_item)
if result:
return result
return None
def _find_node_by_id(self, nodes, target_id):
"""
Sucht rekursiv nach einem Node mit der angegebenen ID.
Args:
nodes: Liste der Nodes zum Durchsuchen
target_id: Die zu suchende ID
Returns:
TreeNode|XslFile|None: Der gefundene Node oder None
"""
for node in nodes:
if node.id == target_id:
return node
# Rekursiv in Knotenn suchen (nur bei TreeNode)
if isinstance(node, TreeNode) and node.children:
found = self._find_node_by_id(node.children, target_id)
if found:
return found
return None
def _create_context_menu_for_type(self, node_type, item):
"""
Erstellt das Kontextmenü für den angegebenen Node-Typ.
Args:
node_type: Der Typ des Nodes ('TreeNode', 'XslFile', 'XmlFile')
item: Das TreeWidgetItem
Returns:
QMenu: Das erstellte Kontextmenü oder None
"""
try:
menu = QMenu(self)
if node_type == "TreeNode":
# Kontextmenü für TreeNode
action_add_child = QAction("Unterknoten hinzufügen", self)
action_add_child.setIcon(QIcon(QIcon.fromTheme("folder-new")))
action_add_child.triggered.connect(lambda: self._add_tree_node_child(item))
menu.addAction(action_add_child)
action_add_xsl = QAction("XSL-Datei hinzufügen", self)
action_add_xsl.setIcon(QIcon(QIcon.fromTheme("document-new")))
action_add_xsl.triggered.connect(lambda: self._add_xsl_file_to_node(item))
menu.addAction(action_add_xsl)
menu.addSeparator()
# Transformations-Aktionen (nur aktiv wenn XML-Dateien vorhanden)
tree_node_obj = item.data(0, Qt.ItemDataRole.UserRole) if item else None
has_xml_files = bool(tree_node_obj and self._has_xml_files_recursive(tree_node_obj))
action_transform = QAction("Alle XML-Dateien transformieren", self)
action_transform.setIcon(QIcon(QIcon.fromTheme("system-run")))
action_transform.triggered.connect(lambda: self._transform_tree_node(item))
action_transform.setEnabled(has_xml_files)
menu.addAction(action_transform)
action_transform_force = QAction("Alle XML-Dateien neu transformieren (force)", self)
action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh")))
action_transform_force.triggered.connect(lambda: self._transform_tree_node(item, force=True))
action_transform_force.setEnabled(has_xml_files)
menu.addAction(action_transform_force)
menu.addSeparator()
# Aktion "Alle Änderungen übernehmen" (nur aktiv wenn Diff-PDFs vorhanden)
diff_pdfs = self._collect_all_diff_pdfs_under_node(tree_node_obj, item) if tree_node_obj else []
has_diff_pdfs = len(diff_pdfs) > 0
action_accept_all = QAction("Alle Änderungen übernehmen", self)
action_accept_all.setIcon(QIcon(QIcon.fromTheme("emblem-default")))
action_accept_all.triggered.connect(lambda: self._accept_all_changes_under_node(item))
action_accept_all.setEnabled(has_diff_pdfs)
menu.addAction(action_accept_all)
menu.addSeparator()
action_edit = QAction("Bearbeiten", self)
action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
action_edit.triggered.connect(lambda: self._edit_tree_node(item))
menu.addAction(action_edit)
action_delete = QAction("Löschen", self)
action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete)))
action_delete.triggered.connect(lambda: self._delete_tree_node(item))
menu.addAction(action_delete)
elif node_type == "XslFile":
# Kontextmenü für XslFile
action_add_xml = QAction("XML-Datei hinzufügen", self)
action_add_xml.setIcon(QIcon(QIcon.fromTheme("document-new")))
action_add_xml.triggered.connect(lambda: self._add_xml_file_to_xsl(item))
menu.addAction(action_add_xml)
menu.addSeparator()
# Transformations-Aktionen (nur aktiv wenn XML-Dateien vorhanden)
xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole) if item else None
has_xml_files = bool(xsl_file_obj and xsl_file_obj.xmls)
action_transform = QAction("Alle XML-Dateien transformieren", self)
action_transform.setIcon(QIcon(QIcon.fromTheme("system-run")))
action_transform.triggered.connect(lambda: self._transform_xsl_file(item))
action_transform.setEnabled(has_xml_files)
menu.addAction(action_transform)
action_transform_force = QAction("Alle XML-Dateien neu transformieren (force)", self)
action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh")))
action_transform_force.triggered.connect(lambda: self._transform_xsl_file(item, force=True))
action_transform_force.setEnabled(has_xml_files)
menu.addAction(action_transform_force)
menu.addSeparator()
# Aktion "Alle Änderungen übernehmen" (nur aktiv wenn Diff-PDFs vorhanden)
diff_pdfs = self._collect_all_diff_pdfs_under_node(xsl_file_obj, item) if xsl_file_obj else []
has_diff_pdfs = len(diff_pdfs) > 0
action_accept_all = QAction("Alle Änderungen übernehmen", self)
action_accept_all.setIcon(QIcon(QIcon.fromTheme("emblem-default")))
action_accept_all.triggered.connect(lambda: self._accept_all_changes_under_node(item))
action_accept_all.setEnabled(has_diff_pdfs)
menu.addAction(action_accept_all)
menu.addSeparator()
action_edit = QAction("Bearbeiten", self)
action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
action_edit.triggered.connect(lambda: self._edit_xsl_file(item))
menu.addAction(action_edit)
action_delete = QAction("Löschen", self)
action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete)))
action_delete.triggered.connect(lambda: self._delete_xsl_file(item))
menu.addAction(action_delete)
elif node_type == "XmlFile":
# Kontextmenü für XmlFile
# Transformations-Aktionen
action_transform = QAction("Transformieren", self)
action_transform.setIcon(QIcon(QIcon.fromTheme("system-run")))
action_transform.triggered.connect(lambda: self._transform_xml_file(item))
menu.addAction(action_transform)
action_transform_force = QAction("Neu transformieren (force)", self)
action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh")))
action_transform_force.triggered.connect(lambda: self._transform_xml_file(item, force=True))
menu.addAction(action_transform_force)
menu.addSeparator()
action_edit = QAction("Bearbeiten", self)
action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
action_edit.triggered.connect(lambda: self._edit_xml_file(item))
menu.addAction(action_edit)
action_delete = QAction("Löschen", self)
action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete)))
action_delete.triggered.connect(lambda: self._delete_xml_file(item))
menu.addAction(action_delete)
else:
# Unbekannter Typ oder leerer Bereich - Menü für Root-Elemente
action_add_tree_node = QAction("Unterknoten hinzufügen", self)
action_add_tree_node.setIcon(QIcon(QIcon.fromTheme("folder-new")))
action_add_tree_node.triggered.connect(lambda: self._add_root_tree_node())
menu.addAction(action_add_tree_node)
return menu
except Exception as e:
logger.error(f"Fehler beim Erstellen des Kontextmenüs: {e}")
return None
def on_alpha_changed(self, alpha_value):
"""
Wird ausgeführt, wenn der Alpha-Slider geändert wird.
Optimiert: Verwendet gecachte Pixmaps statt erneutes PDF-Rendering.
Args:
alpha_value: Der neue Alpha-Wert (-100 bis 100)
"""
logger.debug(f"Alpha geändert auf {alpha_value}")
start_time = time.time()
# Verwende gecachte Pixmaps für schnelle Alpha-Änderungen
self.update_current_display()
alpha_time = time.time() - start_time
logger.debug(f"Alpha-Update in {alpha_time:.6f}s")
def open_settings_dialog(self):
"""Öffnet den Einstellungen-Dialog."""
try:
# Erstelle und zeige den Dialog
dialog = AppSettingsDlg(self, app_settings)
if dialog.exec() == AppSettingsDlg.DialogCode.Accepted:
# Einstellungen wurden gespeichert, hier könnten weitere Aktionen folgen
logger.info("Einstellungen wurden gespeichert")
except Exception as e:
logger.error(f"Fehler beim Öffnen des Einstellungen-Dialogs: {e}")
def open_new_project_dialog(self):
"""Öffnet Pdf-Projekt-Dialog."""
try:
# Erstelle und zeige den PdfProject-Dialog
dialog = PdfProjectDlg(self)
if dialog.exec() == PdfProjectDlg.DialogCode.Accepted:
# Hole die Projektdaten aus dem Dialog
project_data = dialog.get_project_data()
# Erstelle neue ID für das Projekt
new_id = max([p.id for p in app_settings.pdf_projects], default=0) + 1
# Erstelle PdfProject-Objekt
new_project = Project(
id=new_id,
name=project_data["name"],
project_dir=Path(project_data["project_dir"]),
java_vm_id=project_data["java_vm_id"] if project_data["java_vm_id"] != -1 else 1,
diff_pdf_id=project_data["diff_pdf_id"] if project_data["diff_pdf_id"] != -1 else 1,
saxon_jar_id=project_data["saxon_jar_id"] if project_data["saxon_jar_id"] != -1 else 1,
apache_fop_id=project_data["apache_fop_id"] if project_data["apache_fop_id"] != -1 else 1,
xsl_dir_id=project_data["xsl_dir_id"] if project_data["xsl_dir_id"] != -1 else 1,
postgre_sql_db_id=project_data["postgre_sql_db_id"]
if project_data["postgre_sql_db_id"] != -1
else 1,
fop_config_dir=Path(project_data["fop_config_dir"]) if project_data.get("fop_config_dir") else None,
)
# Erstelle Projekt-Ordnerstruktur
self._create_project_structure(new_project)
# Füge das neue Projekt zu app_settings hinzu
app_settings.pdf_projects.append(new_project)
# Speichere app_settings
app_settings.save()
logger.info(f"Neues PDF-Projekt '{project_data['name']}' wurde erstellt und gespeichert")
logger.debug(f"Projekt-ID: {new_id}")
logger.debug(f"Projekt-Ordner: {project_data['project_dir']}")
# Aktualisiere das Projekte-Menü
self._setup_projects_menu()
except Exception as e:
logger.error(f"Fehler beim Erstellen des neuen Projekts: {e}")
def _create_project_structure(self, project: Project):
"""
Erstellt die Ordnerstruktur und project.yaml-Datei für ein neues Projekt.
Args:
project: Das PdfProject-Objekt
"""
try:
project_dir = Path(project.project_dir)
# Erstelle Unterordner
subdirs = ["xml", "new", "diff", "ref", "tmp"]
for subdir in subdirs:
subdir_path = project_dir / subdir
subdir_path.mkdir(parents=True, exist_ok=True)
logger.debug(f"Ordner erstellt: {subdir_path}")
project_yaml_path = project_dir / "project.yaml"
# Erstelle Standard-Projekt-Einstellungen und speichere sie
if not project_yaml_path.exists():
# Erstelle Standard-PdfProjectSettings
default_settings = ProjectData()
# Speichere die Standard-Einstellungen in die project.yaml
default_settings.writeSettings(project_dir=project_dir)
logger.info(f"project.yaml mit Standard-Einstellungen erstellt: {project_yaml_path}")
else:
logger.debug(f"project.yaml existiert bereits: {project_yaml_path}")
except Exception as e:
logger.error(f"Fehler beim Erstellen der Projekt-Struktur: {e}")
raise
def on_button_clicked(self):
"""Wird ausgeführt, wenn der Button geklickt wird."""
logger.debug("Button wurde geklickt!")
def on_thumbnail_clicked(self, event, thumbnail):
"""
Wird ausgeführt, wenn ein Thumbnail angeklickt wird.
Args:
event: Das Maus-Event
thumbnail: Das geklickte Thumbnail-Label
"""
page_info = self.thumbnail_to_page.get(thumbnail)
if page_info:
pdf_filename = page_info["pdf_filename"]
page_num = page_info["page_num"]
logger.debug(f"Thumbnail für Seite {page_num + 1} von {pdf_filename} wurde angeklickt")
# Rendere und zeige die gewählte Seite an
self.render_and_display_page(pdf_filename, page_num)
def apply_zoom(self, zoom_value):
"""
Wendet den Zoom-Faktor auf das aktuelle Bild an.
Optimiert: Verwendet gecachte Pixmaps statt erneutes PDF-Rendering.
Args:
zoom_value: Der neue Zoom-Wert (in Prozent)
"""
self.current_zoom = zoom_value
logger.debug(f"Zoom geändert auf {zoom_value}%")
# Verwende gecachte Pixmaps für schnelle Zoom-Änderungen
self.update_current_display()
def on_fullsize_mouse_press(self, event, fullsize_label):
"""Wird ausgeführt, wenn die Maustaste auf einem großen Bild gedrückt wird."""
if event.button() == Qt.MouseButton.LeftButton:
self.is_dragging = True
self.last_drag_position = event.globalPosition().toPoint()
fullsize_label.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
def on_fullsize_mouse_move(self, event, fullsize_label):
"""Wird ausgeführt, wenn die Maus über einem großen Bild bewegt wird."""
if self.is_dragging and self.last_drag_position is not None:
current_pos = event.globalPosition().toPoint()
delta = current_pos - self.last_drag_position
if abs(delta.x()) >= self.drag_threshold or abs(delta.y()) >= self.drag_threshold:
v_scrollbar = self.ui.scrollArea_2.verticalScrollBar()
h_scrollbar = self.ui.scrollArea_2.horizontalScrollBar()
scroll_delta_y = int(-delta.y() * self.scroll_sensitivity)
scroll_delta_x = int(-delta.x() * self.scroll_sensitivity)
new_v_value = v_scrollbar.value() + scroll_delta_y
new_h_value = h_scrollbar.value() + scroll_delta_x
v_scrollbar.setValue(new_v_value)
h_scrollbar.setValue(new_h_value)
self.last_drag_position = current_pos
def on_fullsize_mouse_release(self, event, fullsize_label):
"""Wird ausgeführt, wenn die Maustaste auf einem großen Bild losgelassen wird."""
if event.button() == Qt.MouseButton.LeftButton:
self.is_dragging = False
self.last_drag_position = None
fullsize_label.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
def _load_nodes_to_tree(self):
"""
Lädt die Nodes aus den Projekt-Einstellungen in das TreeWidget.
Sortiert die Items alphabetisch nach ihrer ID.
"""
logger.info("Lade Nodes in TreeWidget...")
try:
# TreeWidget leeren
self.ui.treeWidget.clear()
# Lösche XML-Item-Map
self.xml_item_map.clear()
# Prüfe ob pdf_project existiert und Nodes hat
if not hasattr(self, "pdf_project") or not self.pdf_project:
logger.warning("Keine Projekt-Einstellungen verfügbar")
return
if not self.pdf_project.nodes:
logger.warning("Keine Nodes in den Projekt-Einstellungen gefunden")
return
# Sortiere Root-Nodes alphabetisch nach ID
sorted_nodes = sorted(self.pdf_project.nodes, key=lambda node: node.id)
# Lade alle Root-Nodes (sortiert)
for node in sorted_nodes:
tree_item = self._create_tree_item_from_node(node)
self.ui.treeWidget.addTopLevelItem(tree_item)
logger.info(f"{len(self.pdf_project.nodes)} Root-Nodes in TreeWidget geladen (alphabetisch sortiert)")
# Aktualisiere Diff-PDF-Anzahl und Icons nach dem Laden
self._update_all_diff_pdf_counts()
self._update_diff_icons_for_existing_pdfs()
except Exception as e:
logger.error(f"Fehler beim Laden der Nodes in TreeWidget: {e}")
def _create_tree_item_from_node(self, node):
"""
Erstellt ein QTreeWidgetItem aus einem TreeNode oder XslFile.
Speichert die vollständigen Node-Daten für spätere Verwendung.
Args:
node: TreeNode oder XslFile Objekt
Returns:
QTreeWidgetItem: Das erstellte Tree-Item mit vollständigen Node-Daten
"""
try:
# Erstelle Tree-Item
item = QTreeWidgetItem()
# Setze die Bezeichnung in Spalte 0
bez_text = str(node.bez) if node.bez else ""
item.setText(0, bez_text)
# Speichere das komplette Node-Objekt als UserRole-Daten
# Dies ermöglicht späteren Zugriff auf alle Node-Eigenschaften
item.setData(0, Qt.ItemDataRole.UserRole, node)
# Setze zusätzliche Informationen in Spalte 1
if isinstance(node, TreeNode):
# TreeNode: Zeige Anzahl der Knoten
child_count = len(node.children) if node.children else 0
item.setText(1, f"{child_count} Knoten")
# Speichere zusätzlich die Node-ID in UserRole+1 für Kompatibilität
item.setData(0, Qt.ItemDataRole.UserRole + 1, node.id)
# Lade Knoten rekursiv (sortiert nach ID)
if node.children:
sorted_children = sorted(node.children, key=lambda child: child.id)
for child in sorted_children:
child_item = self._create_tree_item_from_node(child)
item.addChild(child_item)
# Setze Diff-PDF-Anzahl in Spalte 2 (wird später aktualisiert)
diff_count = self._count_diff_pdfs_under_node(node, item)
if diff_count > 0:
item.setText(2, str(diff_count))
elif isinstance(node, XslFile):
# XslFile: Zeige XSL-Datei-Pfad
item.setText(1, str(node.xsl_file))
# Speichere zusätzlich die Node-ID in UserRole+1 für Kompatibilität
item.setData(0, Qt.ItemDataRole.UserRole + 1, node.id)
# Setze Diff-PDF-Anzahl in Spalte 2 (wird später aktualisiert)
diff_count = self._count_diff_pdfs_under_node(node, item)
if diff_count > 0:
item.setText(2, str(diff_count))
# Lade XML-Dateien als Knoten
if node.xmls:
for xml in node.xmls:
xml_item = QTreeWidgetItem()
xml_item.setText(0, f"XML: {xml.xml.name}")
xml_item.setText(1, str(xml.xml))
# Speichere auch das XmlFile-Objekt für XML-Items
xml_item.setData(0, Qt.ItemDataRole.UserRole, xml)
xml_item.setData(0, Qt.ItemDataRole.UserRole + 1, f"xml_{xml.xml.name}")
# Speichere XSL-ID in Spalte 1, UserRole für einfachen Zugriff
xsl_id_str = "_".join(str(x) for x in node.id)
xml_item.setData(1, Qt.ItemDataRole.UserRole, xsl_id_str)
item.addChild(xml_item)
# Speichere XML-Item für spätere Widget-Updates (Progress Bar, Icon)
# Key: "xml_path|xsl_id" um mehrfache Verwendung derselben XML zu unterstützen
xml_path_str = str(xml.xml)
xsl_id_str = "_".join(str(x) for x in node.id)
map_key = f"{xml_path_str}|{xsl_id_str}"
self.xml_item_map[map_key] = xml_item
logger.debug(f"XML-Item zur Map hinzugefügt: '{map_key}'")
return item
except Exception as e:
logger.error(f"Fehler beim Erstellen des Tree-Items: {e}")
# Fallback: Erstelle einfaches Item
fallback_item = QTreeWidgetItem()
fallback_item.setText(0, "Fehler beim Laden")
fallback_item.setText(1, str(e))
return fallback_item
def _create_centered_progress_bar(self) -> tuple[QWidget, QProgressBar]:
"""
Erstellt eine linksbündige Progress Bar in einem Container-Widget.
Returns:
tuple: (container_widget, progress_bar)
"""
# Container-Widget erstellen
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
# Progress Bar erstellen (indeterminate mode für pulsierenden Effekt)
progress_bar = QProgressBar()
progress_bar.setMinimum(0)
progress_bar.setMaximum(0) # Pulsierend
progress_bar.setMaximumWidth(80) # Kompakte Breite
progress_bar.setMaximumHeight(16) # Kompakte Höhe
progress_bar.setTextVisible(False)
layout.addWidget(progress_bar)
return container, progress_bar
def _create_centered_diff_icon(self, xml_file_path: Path, xsl_id_str: str) -> QWidget:
"""
Erstellt ein linksbündiges, nicht-klickbares Icon für Diff-PDF.
Args:
xml_file_path: Pfad zur XML-Datei (relativ)
xsl_id_str: XSL-ID als String (z.B. "2002_1_128")
Returns:
QWidget: Container mit Icon
"""
# Container-Widget
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
# Icon-Label
icon_label = QLabel()
# Icon für Diff-View mit Fallbacks
icon = QIcon.fromTheme("view-split-left-right")
if icon.isNull():
icon = QIcon.fromTheme("vcs-diff")
if icon.isNull():
icon = QIcon.fromTheme("system-search") # Letzter Fallback
icon_label.setPixmap(icon.pixmap(16, 16))
icon_label.setToolTip("Diff-PDF vorhanden (wird automatisch geladen bei Selektion)")
layout.addWidget(icon_label)
return container
def _load_pdf_for_comparison(self, xml_file_path: Path, xsl_id_str: str):
"""
Lädt die PDFs (diff, ref, new) einer Transformation in den Vergleichs-Viewer.
Args:
xml_file_path: Pfad zur XML-Datei (relativ)
xsl_id_str: XSL-ID als String (z.B. "2002_1_128")
"""
try:
if not self.project:
QMessageBox.warning(self, "Fehler", "Kein Projekt geöffnet")
return
# Ermittle PDF-Dateinamen basierend auf XML und XSL-ID
xml_stem = xml_file_path.stem
pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf"
# Pfade zu den drei PDFs
diff_dir = self.project.project_dir / "diff"
ref_dir = self.project.project_dir / "ref"
new_dir = self.project.project_dir / "new"
diff_pdf_path = diff_dir / pdf_basename
ref_pdf_path = ref_dir / pdf_basename
new_pdf_path = new_dir / pdf_basename
# Prüfe ob PDFs existieren
if not diff_pdf_path.exists():
QMessageBox.information(self, "Keine Diff-PDF", f"Diff-PDF nicht gefunden:\n{pdf_basename}")
return
if not ref_pdf_path.exists() or not new_pdf_path.exists():
QMessageBox.warning(
self,
"Fehlende PDFs",
f"Ref-PDF oder New-PDF nicht gefunden:\n{pdf_basename}\n\nNur Diff-PDF vorhanden.",
)
return
logger.info(f"Lade PDFs für Vergleich: {pdf_basename}")
# Entferne bestehende Widgets aus den Layouts
self._clear_layout(self.ui.verticalLayout_2)
self._clear_layout(self.ui.verticalLayout_3)
# Dicts zurücksetzen
self.thumbnail_to_page = {}
self.pdf_documents = {}
self.current_rendered_pixmaps = None
self.fullsize_label = None # Label wurde durch _clear_layout gelöscht
# Alle drei PDF-Dateien öffnen mit QtPdf
diff_doc = QPdfDocument()
ref_doc = QPdfDocument()
new_doc = QPdfDocument()
# PDF-Dateien laden
diff_doc.load(str(diff_pdf_path))
ref_doc.load(str(ref_pdf_path))
new_doc.load(str(new_pdf_path))
# Warten bis PDFs geladen sind
if (
diff_doc.status() != QPdfDocument.Status.Ready
or ref_doc.status() != QPdfDocument.Status.Ready
or new_doc.status() != QPdfDocument.Status.Ready
):
QMessageBox.critical(self, "Fehler", f"Fehler beim Laden der PDFs:\n{pdf_basename}")
return
# PDF-Dokumente speichern
self.pdf_documents[pdf_basename] = {"diff": diff_doc, "ref": ref_doc, "new": new_doc}
logger.info(f"PDFs geladen: {pdf_basename}")
logger.info(f" diff: {diff_doc.pageCount()} Seiten")
logger.info(f" ref: {ref_doc.pageCount()} Seiten")
logger.info(f" new: {new_doc.pageCount()} Seiten")
# Nehme die Seitenzahl der diff-PDF als Basis
max_pages = diff_doc.pageCount()
# Erstelle Thumbnails für alle Seiten
for page_num in range(max_pages):
# Nur diff-Seite für Thumbnail rendern
page_size = diff_doc.pagePointSize(page_num)
# Skalierung für Thumbnail
scale_factor = 200.0 / page_size.width() # 200 Pixel Breite
# Seite rendern
page_image = diff_doc.render(
page_num,
QSize(int(page_size.width() * scale_factor), int(page_size.height() * scale_factor)),
)
diff_pixmap = QPixmap.fromImage(page_image)
# Thumbnail erstellen und zur linken Spalte hinzufügen
thumbnail = QLabel()
thumbnail.setObjectName(f"thumbnail_{pdf_basename}_page_{page_num + 1}")
thumbnail.setPixmap(diff_pixmap.scaledToWidth(200, Qt.TransformationMode.SmoothTransformation))
thumbnail.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
thumbnail.setMouseTracking(True)
self.ui.verticalLayout_2.addWidget(thumbnail)
# Seitennummer für Thumbnail anzeigen
thumbnail_info = QLabel(f"Seite {page_num + 1}")
thumbnail_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.ui.verticalLayout_2.addWidget(thumbnail_info)
# Beziehung zwischen Thumbnail und Seitennummer speichern
self.thumbnail_to_page[thumbnail] = {"pdf_filename": pdf_basename, "page_num": page_num}
# Click-Event für das Thumbnail einrichten
thumbnail.mousePressEvent = lambda event, t=thumbnail: self.on_thumbnail_clicked(event, t)
# Erstelle das Vollbild-Label für die rechte Spalte (falls noch nicht vorhanden)
if self.fullsize_label is None:
self.fullsize_label = QLabel()
self.fullsize_label.setObjectName("fullsize_current_page")
self.fullsize_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.fullsize_label.setCursor(QCursor(Qt.CursorShape.OpenHandCursor))
self.ui.verticalLayout_3.addWidget(self.fullsize_label)
# Drag-to-Scroll Events für das große Bild einrichten
self.fullsize_label.mousePressEvent = lambda event: self.on_fullsize_mouse_press(
event, self.fullsize_label
)
self.fullsize_label.mouseMoveEvent = lambda event: self.on_fullsize_mouse_move(
event, self.fullsize_label
)
self.fullsize_label.mouseReleaseEvent = lambda event: self.on_fullsize_mouse_release(
event, self.fullsize_label
)
# Setze die aktuelle PDF
self.current_pdf = pdf_basename
# Speichere Diff-PDF-Informationen für Accept Changes
self.current_diff_xml_path = xml_file_path
self.current_diff_xsl_id = xsl_id_str
# Aktiviere Accept-Changes-Button
self.ui.accept_changes.setEnabled(True)
# Zeige die erste Seite initial an
self.render_and_display_page(pdf_basename, 0)
logger.info(f"PDF-Vergleich geladen: {pdf_basename}")
except Exception as e:
logger.error(f"Fehler beim Laden der PDFs für Vergleich: {e}")
QMessageBox.critical(self, "Fehler", f"Konnte PDFs nicht laden:\n{str(e)}")
def _update_diff_icons_for_existing_pdfs(self):
"""
Durchläuft alle XML-Items und setzt Icons für bereits existierende Diff-PDFs.
Wird nach dem Laden eines Projekts aufgerufen.
"""
if not hasattr(self, "project") or not self.project:
logger.debug("Kein Projekt geladen, überspringe Diff-Icon-Update")
return
diff_dir = self.project.project_dir / "diff"
if not diff_dir.exists():
logger.debug(f"Diff-Ordner existiert nicht: {diff_dir}")
return
logger.info("Aktualisiere Diff-Icons für existierende PDFs...")
logger.info(f"XML-Item-Map hat {len(self.xml_item_map)} Einträge")
# Durchlaufe alle XML-Items in der Map
icon_count = 0
for map_key, tree_item in self.xml_item_map.items():
# Map-Key hat Format "xml_path|xsl_id"
parts = map_key.split("|")
if len(parts) != 2:
continue
xml_path_str, xsl_id_str = parts
xml_path = Path(xml_path_str)
xml_stem = xml_path.stem
# Diff-PDF-Dateiname: "{xml_stem}_xsl_{xsl_id_str}.pdf"
expected_pdf = diff_dir / f"{xml_stem}_xsl_{xsl_id_str}.pdf"
if expected_pdf.exists():
# Icon setzen
icon_widget = self._create_centered_diff_icon(xml_path, xsl_id_str)
self.ui.treeWidget.setItemWidget(tree_item, 2, icon_widget)
icon_count += 1
logger.debug(f"Diff-Icon für existierende PDF gesetzt: {map_key}")
logger.info(f"{icon_count} Diff-Icons für existierende PDFs gesetzt")
# Kontextmenü-Aktionen für TreeNode
def _add_tree_node_child(self, parent_item):
"""Fügt einen Unterknoten zu einem TreeNode hinzu."""
logger.debug(f"Unterknoten zu TreeNode hinzufügen: {parent_item.text(0)}")
# TODO: Dialog zum Eingeben der Node-Daten öffnen
def _add_xsl_file_to_node(self, parent_item):
"""Fügt eine XSL-Datei zu einem TreeNode hinzu."""
logger.debug(f"XSL-Datei zu TreeNode hinzufügen: {parent_item.text(0)}")
# TODO: Dialog zum Auswählen der XSL-Datei öffnen
def _edit_tree_node(self, item):
"""
Bearbeitet einen TreeNode.
Args:
item: Das TreeWidgetItem des TreeNode
"""
logger.debug(f"TreeNode bearbeiten: {item.text(0)}")
try:
# Hole das Node-Objekt aus dem TreeWidgetItem
node = item.data(0, Qt.ItemDataRole.UserRole)
if not node or not isinstance(node, TreeNode):
QMessageBox.warning(self, "Warnung", "Kein gültiger TreeNode gefunden.")
return
# Prüfe ob Projekt verfügbar ist
if not self.pdf_project:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen verfügbar.")
return
# Sammle Eltern-Parameter
parent_params = self._collect_parent_params(item)
# Erstelle und zeige den Dialog
dialog = TreeNodeEditDialog(self, node, parent_params)
if dialog.exec() == TreeNodeEditDialog.DialogCode.Accepted:
# Hole die bearbeiteten Daten
data = dialog.get_data()
if data:
# Aktualisiere den Node
node.bez = data["bez"]
node.xslt_params = data["xslt_params"]
logger.info(f"TreeNode '{node.bez}' wurde aktualisiert")
logger.debug(f"XSLT-Parameter: {node.xslt_params}")
# Speichere die Änderungen
self._save_project_settings()
# Aktualisiere das TreeWidget
self._load_nodes_to_tree()
# Wenn Force-Transformation gewünscht, führe sie aus
if data.get("force_transform", False):
# Finde das neue Item nach dem Neuladen
new_item = self._find_item_by_node(node)
if new_item:
logger.info(f"Starte Force-Transformation für TreeNode '{node.bez}'")
self._transform_tree_node(new_item, force=True)
else:
logger.warning(f"Konnte Item für TreeNode '{node.bez}' nicht finden")
# QMessageBox.information(self, "Erfolg", "TreeNode wurde erfolgreich aktualisiert.")
except Exception as e:
error_msg = f"Fehler beim Bearbeiten des TreeNode: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _delete_tree_node(self, item):
"""Löscht einen TreeNode."""
logger.debug(f"TreeNode löschen: {item.text(0)}")
# TODO: Bestätigungsdialog und Löschung implementieren
# Kontextmenü-Aktionen für XslFile
def _add_xml_file_to_xsl(self, parent_item):
"""
Fügt eine XML-Datei zu einer XSL-Datei hinzu.
Args:
parent_item: Das TreeWidgetItem des XslFile-Nodes
"""
logger.debug(f"XML-Datei zu XslFile hinzufügen: {parent_item.text(0)}")
try:
# Prüfe ob ein Projekt geladen ist
if not hasattr(self, "project") or not self.project:
QMessageBox.warning(self, "Warnung", "Kein Projekt geladen. Bitte öffnen Sie zuerst ein Projekt.")
return
if not hasattr(self, "pdf_project") or not self.pdf_project:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen geladen.")
return
# Hole das XslFile-Node-Objekt direkt aus dem TreeWidgetItem
xsl_node = parent_item.data(0, Qt.ItemDataRole.UserRole)
if not xsl_node or not isinstance(xsl_node, XslFile):
QMessageBox.warning(self, "Warnung", "Keine gültige XSL-Datei-Node gefunden.")
return
# Öffne Datei-Dialog zum Auswählen der XML-Datei
xml_file_path, _ = QFileDialog.getOpenFileName(
self, "XML-Datei auswählen", "", "XML-Dateien (*.xml);;Alle Dateien (*)"
)
if not xml_file_path:
# Benutzer hat abgebrochen
return
xml_file_path = Path(xml_file_path)
# Prüfe ob die Datei existiert
if not xml_file_path.exists():
QMessageBox.critical(self, "Fehler", f"Die ausgewählte XML-Datei existiert nicht:\n{xml_file_path}")
return
# Erstelle xml-Ordner im Projekt-Verzeichnis falls er nicht existiert
xml_dir = Path(self.project.project_dir) / "xml"
xml_dir.mkdir(parents=True, exist_ok=True)
# Bestimme den Ziel-Pfad in xml-Ordner
target_xml_path = xml_dir / xml_file_path.name
# Prüfe ob eine Datei mit gleichem Namen bereits existiert
if target_xml_path.exists():
reply = QMessageBox.question(
self,
"Datei existiert bereits",
f"Eine XML-Datei mit dem Namen '{xml_file_path.name}' existiert bereits im xml-Ordner.\n\n"
"Möchten Sie sie überschreiben?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
# Kopiere die XML-Datei in den xml-Ordner
shutil.copy2(xml_file_path, target_xml_path)
logger.info(f"XML-Datei kopiert: {xml_file_path} -> {target_xml_path}")
# Erstelle relatives Path zur XML-Datei (relativ zum xml-Ordner)
relative_xml_path = Path("xml") / xml_file_path.name
# Prüfe ob diese XML-Datei bereits in der XslFile-Node vorhanden ist
existing_xml = None
for xml_file in xsl_node.xmls:
if xml_file.xml == relative_xml_path:
existing_xml = xml_file
break
if existing_xml:
QMessageBox.information(
self,
"XML-Datei bereits vorhanden",
f"Die XML-Datei '{xml_file_path.name}' ist bereits in dieser XSL-Datei enthalten.",
)
return
# Erstelle neues XmlFile-Objekt und füge es zur XslFile-Node hinzu
new_xml_file = XmlFile(xml=relative_xml_path)
xsl_node.xmls.append(new_xml_file)
logger.info(f"XML-Datei '{xml_file_path.name}' zu XslFile-Node '{xsl_node.bez}' hinzugefügt")
# Berechne Hash für die neue XML-Datei
self._calculate_hash_for_xml_file(new_xml_file)
# Speichere die aktualisierten Projekt-Einstellungen
self._save_project_settings()
# Aktualisiere das TreeWidget
self._load_nodes_to_tree()
# QMessageBox.information(
# self,
# "Erfolg",
# f"XML-Datei '{xml_file_path.name}' wurde erfolgreich hinzugefügt und in den xml-Ordner kopiert."
# )
except Exception as e:
error_msg = f"Fehler beim Hinzufügen der XML-Datei: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _edit_xsl_file(self, item):
"""
Bearbeitet eine XSL-Datei.
Args:
item: Das TreeWidgetItem des XslFile
"""
logger.debug(f"XslFile bearbeiten: {item.text(0)}")
try:
# Hole das Node-Objekt aus dem TreeWidgetItem
node = item.data(0, Qt.ItemDataRole.UserRole)
if not node or not isinstance(node, XslFile):
QMessageBox.warning(self, "Warnung", "Keine gültige XSL-Datei gefunden.")
return
# Sammle Eltern-Parameter
parent_params = self._collect_parent_params(item)
# Erstelle und zeige den Dialog
dialog = XslFileEditDialog(self, node, parent_params)
if dialog.exec() == XslFileEditDialog.DialogCode.Accepted:
# Hole die bearbeiteten Daten
data = dialog.get_data()
if data:
# Aktualisiere den Node
node.bez = data["bez"]
node.xslt_params = data["xslt_params"]
logger.info(f"XslFile '{node.bez}' wurde aktualisiert")
logger.debug(f"XSLT-Parameter: {node.xslt_params}")
# Speichere die Änderungen
self._save_project_settings()
# Aktualisiere das TreeWidget
self._load_nodes_to_tree()
# Wenn Force-Transformation gewünscht, führe sie aus
if data.get("force_transform", False):
# Finde das neue Item nach dem Neuladen
new_item = self._find_item_by_node(node)
if new_item:
logger.info(f"Starte Force-Transformation für XslFile '{node.bez}'")
self._transform_xsl_file(new_item, force=True)
else:
logger.warning(f"Konnte Item für XslFile '{node.bez}' nicht finden")
# QMessageBox.information(self, "Erfolg", "XSL-Datei wurde erfolgreich aktualisiert.")
except Exception as e:
error_msg = f"Fehler beim Bearbeiten der XSL-Datei: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _delete_xsl_file(self, item):
"""Löscht eine XSL-Datei."""
logger.debug(f"XslFile löschen: {item.text(0)}")
# TODO: Bestätigungsdialog und Löschung implementieren
# Kontextmenü-Aktionen für XmlFile
def _edit_xml_file(self, item):
"""Bearbeitet eine XML-Datei."""
logger.debug(f"XmlFile bearbeiten: {item.text(0)}")
# TODO: Dialog zum Bearbeiten der XML-Datei öffnen
def _delete_xml_file(self, item):
"""
Löscht eine XML-Datei aus einem XSL-Knoten.
Args:
item: Das TreeWidgetItem der XML-Datei
"""
logger.debug(f"XmlFile löschen: {item.text(0)}")
try:
# Prüfe ob ein Projekt geladen ist
if not hasattr(self, "project") or not self.project:
QMessageBox.warning(self, "Warnung", "Kein Projekt geladen. Bitte öffnen Sie zuerst ein Projekt.")
return
if not hasattr(self, "pdf_project") or not self.pdf_project:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen geladen.")
return
# Hole das XmlFile-Objekt aus dem TreeWidgetItem
xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
if not xml_file_obj or not isinstance(xml_file_obj, XmlFile):
QMessageBox.warning(self, "Warnung", "Keine gültige XML-Datei gefunden.")
return
# Hole das Eltern-Item (sollte ein XslFile sein)
parent_item = item.parent()
if not parent_item:
QMessageBox.warning(self, "Warnung", "Eltern-XSL-Datei nicht gefunden.")
return
# Hole das XslFile-Objekt aus dem Eltern-Item
xsl_file_obj = parent_item.data(0, Qt.ItemDataRole.UserRole)
if not xsl_file_obj or not isinstance(xsl_file_obj, XslFile):
QMessageBox.warning(self, "Warnung", "Keine gültige Eltern-XSL-Datei gefunden.")
return
# Bestätigungsdialog anzeigen
xml_filename = xml_file_obj.xml.name
reply = QMessageBox.question(
self,
"XML-Datei löschen",
f"Möchten Sie die XML-Datei '{xml_filename}' aus der XSL-Datei '{xsl_file_obj.bez}' entfernen?\n\n"
"Die XML-Datei wird nur aus der Zuordnung entfernt, nicht physisch gelöscht.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
logger.debug("Löschung abgebrochen")
return
# Entferne die XML-Datei aus der XslFile-Node
xml_files_before = len(xsl_file_obj.xmls)
xsl_file_obj.xmls = [xml for xml in xsl_file_obj.xmls if xml.xml != xml_file_obj.xml]
xml_files_after = len(xsl_file_obj.xmls)
if xml_files_before == xml_files_after:
QMessageBox.warning(self, "Warnung", "XML-Datei konnte nicht aus der XSL-Datei entfernt werden.")
return
logger.info(f"XML-Datei '{xml_filename}' aus XSL-Datei '{xsl_file_obj.bez}' entfernt")
# Frage ob die physische Datei auch gelöscht werden soll
xml_file_path = Path(self.project.project_dir) / xml_file_obj.xml
if xml_file_path.exists():
# Prüfe ob die XML-Datei noch in anderen XSL-Dateien verwendet wird
is_used_elsewhere = self._is_xml_file_used_elsewhere(xml_file_obj.xml, xsl_file_obj)
if not is_used_elsewhere:
delete_reply = QMessageBox.question(
self,
"Physische Datei löschen",
f"Die XML-Datei '{xml_filename}' wird in keiner anderen XSL-Datei verwendet.\n\n"
"Möchten Sie auch die physische Datei aus dem xml-Ordner löschen?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if delete_reply == QMessageBox.StandardButton.Yes:
try:
xml_file_path.unlink()
logger.info(f"Physische XML-Datei gelöscht: {xml_file_path}")
except Exception as e:
QMessageBox.warning(self, "Warnung", f"Fehler beim Löschen der physischen Datei:\n{str(e)}")
else:
logger.info(
f"XML-Datei '{xml_filename}' wird noch in anderen XSL-Dateien verwendet - physische Datei nicht gelöscht"
)
# Speichere die aktualisierten Projekt-Einstellungen
self._save_project_settings()
# Aktualisiere das TreeWidget
self._load_nodes_to_tree()
logger.info(f"XML-Datei '{xml_filename}' erfolgreich entfernt")
except Exception as e:
error_msg = f"Fehler beim Löschen der XML-Datei: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _is_xml_file_used_elsewhere(self, xml_path, exclude_xsl_file):
"""
Prüft ob eine XML-Datei noch in anderen XSL-Dateien verwendet wird.
Args:
xml_path: Pfad zur XML-Datei (relativ)
exclude_xsl_file: XSL-Datei die ausgeschlossen werden soll
Returns:
bool: True wenn die XML-Datei noch anderswo verwendet wird
"""
try:
# Prüfe ob pdf_project und nodes existieren
if not self.pdf_project or not self.pdf_project.nodes:
return False # Keine Nodes vorhanden, also nicht verwendet
return self._check_xml_usage_recursive(self.pdf_project.nodes, xml_path, exclude_xsl_file)
except Exception as e:
logger.error(f"Fehler beim Prüfen der XML-Datei-Verwendung: {e}")
return True # Im Zweifelsfall annehmen, dass sie verwendet wird
def _check_xml_usage_recursive(self, nodes, xml_path, exclude_xsl_file):
"""
Prüft rekursiv ob eine XML-Datei in den Nodes verwendet wird.
Args:
nodes: Liste der zu prüfenden Nodes
xml_path: Pfad zur XML-Datei (relativ)
exclude_xsl_file: XSL-Datei die ausgeschlossen werden soll
Returns:
bool: True wenn die XML-Datei gefunden wird
"""
for node in nodes:
if isinstance(node, XslFile) and node != exclude_xsl_file:
# Prüfe ob diese XSL-Datei die XML-Datei verwendet
for xml_file in node.xmls:
if xml_file.xml == xml_path:
return True
elif isinstance(node, TreeNode) and node.children:
# Rekursiv in Knoten suchen
if self._check_xml_usage_recursive(node.children, xml_path, exclude_xsl_file):
return True
return False
# Kontextmenü-Aktionen für Root-Elemente (Unbekannter Typ)
def _add_root_tree_node(self):
"""Fügt einen neuen TreeNode als Root-Element hinzu."""
logger.debug("Neuen TreeNode als Root-Element hinzufügen")
# TODO: Dialog zum Eingeben der TreeNode-Daten öffnen
def on_load_from_fn2_clicked(self):
"""
Wird ausgeführt, wenn der Button "lade aus FN2" geklickt wird.
Führt SQL-Abfrage aus und aktualisiert die Projekt-Nodes.
"""
logger.debug("Button 'lade aus FN2' wurde geklickt!")
try:
# Prüfe ob ein Projekt geladen ist
if not hasattr(self, "project") or not self.project:
QMessageBox.warning(self, "Warnung", "Kein Projekt geladen. Bitte öffnen Sie zuerst ein Projekt.")
return
# Hole das aktuelle Projekt aus app_settings
if not self.project:
QMessageBox.warning(self, "Warnung", "Aktuelles Projekt nicht in den Einstellungen gefunden.")
return
# Hole die PostgreSQL-Datenbank-Konfiguration
db_config = self._get_database_config(self.project.postgre_sql_db_id)
if not db_config:
QMessageBox.warning(self, "Warnung", "PostgreSQL-Datenbank-Konfiguration nicht gefunden.")
return
# Führe SQL-Abfrage aus
df = self._execute_sql_query(db_config)
if df is None:
return # Fehler bereits angezeigt
# Verarbeite die Daten wie in readCsv.py
new_nodes = self._process_sql_data(df)
# Merge mit vorhandenen Nodes
self._merge_nodes_with_existing(new_nodes)
# Speichere die aktualisierten Projekt-Einstellungen
self._save_project_settings()
# Lade das Projekt neu
self._load_nodes_to_tree()
# QMessageBox.information(self, "Erfolg", "Daten erfolgreich aus FN2 geladen und Projekt aktualisiert!")
except Exception as e:
logger.error(f"Fehler beim Laden aus FN2: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Laden aus FN2:\n{str(e)}")
def _get_database_config(self, db_id):
"""
Holt die Datenbank-Konfiguration anhand der ID.
Args:
db_id: ID der PostgreSQL-Datenbank
Returns:
PostgreSqlDb|None: Die Datenbank-Konfiguration oder None
"""
for db in app_settings.postgresql_dbs:
if db.id == db_id:
return db
return None
def _execute_sql_query(self, db_config):
"""
Führt die SQL-Abfrage aus der data.sql Datei aus.
Args:
db_config: PostgreSQL-Datenbank-Konfiguration
Returns:
pl.DataFrame|None: Die Abfrageergebnisse oder None bei Fehler
"""
try:
# Lade SQL-Abfrage aus Datei
sql_file_path = Path("src/res/data.sql")
if not sql_file_path.exists():
QMessageBox.critical(self, "Fehler", f"SQL-Datei nicht gefunden: {sql_file_path}")
return None
with open(sql_file_path, "r", encoding="utf-8") as f:
sql_query = f.read()
logger.debug(f"SQL-Abfrage geladen: {len(sql_query)} Zeichen")
# Verbindung zur PostgreSQL-Datenbank herstellen
connection_string = (
"postgresql://"
f"{db_config.username}:"
f"{db_config.password}@"
f"{db_config.host}:"
f"{db_config.port}/"
f"{db_config.database}?"
f"sslmode={db_config.ssl_mode.value}"
)
logger.info(f"Verbinde zu PostgreSQL: {db_config.host}:{db_config.port}/{db_config.database}")
df = pl.read_database_uri(sql_query, connection_string, engine="connectorx").sort(
["reporttyp_bez", "report_bez", "repfile_bez"]
)
return df
except Exception as e:
error_msg = f"Fehler beim Ausführen der SQL-Abfrage: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
return None
def _process_sql_data(self, df):
"""
Verarbeitet die SQL-Daten wie in readCsv.py und erstellt Node-Struktur.
Args:
df: Polars DataFrame mit den SQL-Ergebnissen
Returns:
list[TreeNode]: Liste der erstellten Root-Nodes
"""
try:
start_time = time.time()
# Gruppiere die Daten wie in readCsv.py
ebene_1 = df.group_by(["reporttyp", "reporttyp_bez"]).len()
ebene_2 = df.group_by(["reporttyp", "report", "report_bez"]).len()
ebene_3 = df.group_by(["reporttyp", "report", "repfile", "repfile_bez", "xsl_datei"]).len()
group_time = time.time() - start_time
logger.debug(f"Performance: Gruppierung in {group_time:.3f}s")
new_nodes = []
start_time = time.time()
# Erstelle Node-Struktur wie in readCsv.py
for r1 in ebene_1.rows(named=True):
tn_1 = TreeNode(id=(r1["reporttyp"],), bez=r1["reporttyp_bez"], children=[])
r1_children = ebene_2.filter(pl.col("reporttyp") == r1["reporttyp"])
for r2 in r1_children.rows(named=True):
tn_2 = TreeNode(id=(r2["reporttyp"], r2["report"]), bez=r2["report_bez"], children=[])
r2_children = ebene_3.filter(
(pl.col("reporttyp") == r1["reporttyp"]) & (pl.col("report") == r2["report"])
)
for r3 in r2_children.rows(named=True):
x = XslFile(
id=(r3["reporttyp"], r3["report"], r3["repfile"]),
bez=r3["repfile_bez"],
xsl_file=Path(r3["xsl_datei"]),
xmls=[],
)
tn_2.children.append(x)
tn_1.children.append(tn_2)
new_nodes.append(tn_1)
nodes_time = time.time() - start_time
logger.debug(f"Performance: Node-Erstellung in {nodes_time:.3f}s")
logger.info(f"Erstellt: {len(new_nodes)} Root-Nodes")
return new_nodes
except Exception as e:
logger.error(f"Fehler beim Verarbeiten der SQL-Daten: {e}")
raise
def _merge_nodes_with_existing(self, new_nodes):
"""
Merged neue Nodes mit vorhandenen Nodes basierend auf IDs.
Überschreibt nur einzelne Eigenschaften, nicht ganze Nodes.
Args:
new_nodes: Liste der neuen Nodes
"""
try:
logger.info("Merge neue Nodes mit vorhandenen...")
# Erstelle ein Dictionary der neuen Nodes für schnellen Zugriff
new_nodes_dict = {}
self._build_nodes_dict(new_nodes, new_nodes_dict)
logger.debug(f"Neue Nodes Dictionary erstellt: {len(new_nodes_dict)} Einträge")
# Merge mit vorhandenen Nodes
if self.pdf_project and self.pdf_project.nodes:
self._merge_nodes_recursive(self.pdf_project.nodes, new_nodes_dict)
# Füge komplett neue Root-Nodes hinzu
if self.pdf_project and self.pdf_project.nodes:
existing_root_ids = {node.id for node in self.pdf_project.nodes}
for new_node in new_nodes:
if new_node.id not in existing_root_ids:
self.pdf_project.nodes.append(new_node)
logger.info(f"Neue Root-Node hinzugefügt: {new_node.bez}")
elif self.pdf_project:
# Wenn keine Nodes vorhanden sind, füge alle neuen Nodes hinzu
self.pdf_project.nodes = new_nodes
logger.info(f"Alle {len(new_nodes)} Root-Nodes hinzugefügt (keine vorhandenen Nodes)")
logger.info("Merge abgeschlossen")
except Exception as e:
logger.error(f"Fehler beim Mergen der Nodes: {e}")
raise
def _build_nodes_dict(self, nodes, nodes_dict):
"""
Erstellt rekursiv ein Dictionary aller Nodes für schnellen ID-basierten Zugriff.
Args:
nodes: Liste der Nodes
nodes_dict: Dictionary zum Füllen
"""
for node in nodes:
nodes_dict[node.id] = node
if isinstance(node, TreeNode) and node.children:
self._build_nodes_dict(node.children, nodes_dict)
def _merge_nodes_recursive(self, existing_nodes, new_nodes_dict):
"""
Merged rekursiv vorhandene Nodes mit neuen Nodes.
Args:
existing_nodes: Liste der vorhandenen Nodes
new_nodes_dict: Dictionary der neuen Nodes
"""
for existing_node in existing_nodes:
if existing_node.id in new_nodes_dict:
new_node = new_nodes_dict[existing_node.id]
# Aktualisiere nur die Bezeichnung, falls sie sich geändert hat
if existing_node.bez != new_node.bez:
logger.info(
f"Aktualisiere Bezeichnung für Node {existing_node.id}: '{existing_node.bez}' -> '{new_node.bez}'"
)
existing_node.bez = new_node.bez
# Für XslFile: Aktualisiere xsl_file Pfad
if isinstance(existing_node, XslFile) and isinstance(new_node, XslFile):
if existing_node.xsl_file != new_node.xsl_file:
logger.info(
f"Aktualisiere XSL-Datei für Node {existing_node.id}: '{existing_node.xsl_file}' -> '{new_node.xsl_file}'"
)
existing_node.xsl_file = new_node.xsl_file
# Rekursiv für Knoten (nur bei TreeNode)
if isinstance(existing_node, TreeNode) and existing_node.children:
self._merge_nodes_recursive(existing_node.children, new_nodes_dict)
# Füge neue Knoten hinzu, die noch nicht existieren
if existing_node.id in new_nodes_dict:
new_node = new_nodes_dict[existing_node.id]
if isinstance(new_node, TreeNode) and new_node.children:
existing_child_ids = {child.id for child in existing_node.children}
for new_child in new_node.children:
if new_child.id not in existing_child_ids:
existing_node.children.append(new_child)
logger.info(f"Neues Kind hinzugefügt zu Node {existing_node.id}: {new_child.bez}")
def _collect_parent_params(self, item):
"""
Sammelt die XSLT-Parameter aller Eltern-Nodes von der Wurzel bis zum angegebenen Item.
Parameter werden von oben nach unten gesammelt, wobei tiefere Ebenen höhere Priorität haben.
Args:
item: Das TreeWidgetItem (kann TreeNode oder XslFile sein)
Returns:
dict: Dictionary mit allen gesammelten Parametern (tiefere Ebenen überschreiben höhere)
"""
parent_params = {}
try:
# Sammle alle Eltern-Items in einer Liste (von unten nach oben)
parents = []
current_item = item.parent()
while current_item:
parents.append(current_item)
current_item = current_item.parent()
# Kehre Liste um, sodass wir von Wurzel zu Kind iterieren
parents.reverse()
# Sammle Parameter von Wurzel zu Kind (Kind überschreibt Eltern)
for parent_item in parents:
parent_node = parent_item.data(0, Qt.ItemDataRole.UserRole)
if parent_node and hasattr(parent_node, "xslt_params") and parent_node.xslt_params:
# Update überschreibt vorherige Werte (höhere Priorität für tiefere Ebenen)
parent_params.update(parent_node.xslt_params)
logger.debug(f"Gesammelte Eltern-Parameter: {parent_params}")
return parent_params
except Exception as e:
logger.error(f"Fehler beim Sammeln der Eltern-Parameter: {e}")
return {}
def _save_project_settings(self):
"""
Speichert die aktualisierten Projekt-Einstellungen.
Args:
current_project: Das aktuelle Projekt
"""
try:
# Prüfe ob pdf_project und project existieren
if not self.pdf_project:
logger.warning("Keine Projekt-Einstellungen zum Speichern verfügbar")
return
if not self.project or not self.project.project_dir:
logger.warning("Kein Projekt-Verzeichnis zum Speichern verfügbar")
return
start_time = time.time()
# Speichere in project.yaml im Projekt-Verzeichnis
self.pdf_project.writeSettings(project_dir=self.project.project_dir)
dump_time = time.time() - start_time
logger.debug(f"Performance: Projekt-Einstellungen gespeichert in {dump_time:.3f}s")
except Exception as e:
logger.error(f"Fehler beim Speichern der Projekt-Einstellungen: {e}")
raise
def _setup_drag_drop(self):
"""Aktiviert Drag&Drop für das TreeWidget."""
try:
# Aktiviere Drag&Drop für das TreeWidget
self.ui.treeWidget.setAcceptDrops(True)
self.ui.treeWidget.setDragDropMode(self.ui.treeWidget.DragDropMode.DropOnly)
# Überschreibe die Drag&Drop-Events
self.ui.treeWidget.dragEnterEvent = self.tree_drag_enter_event
self.ui.treeWidget.dragMoveEvent = self.tree_drag_move_event
self.ui.treeWidget.dropEvent = self.tree_drop_event
logger.debug("Drag&Drop für TreeWidget aktiviert")
except Exception as e:
logger.error(f"Fehler beim Aktivieren von Drag&Drop: {e}")
def tree_drag_enter_event(self, event: QDragEnterEvent):
"""
Wird ausgeführt, wenn ein Drag-Vorgang über das TreeWidget beginnt.
Args:
event: Das Drag-Enter-Event
"""
try:
# Prüfe ob URLs (Dateien) gedraggt werden
if event.mimeData().hasUrls():
urls = event.mimeData().urls()
# Prüfe ob mindestens eine XML-Datei dabei ist
xml_files = [url.toLocalFile() for url in urls if url.toLocalFile().lower().endswith(".xml")]
if xml_files:
event.acceptProposedAction()
logger.debug(f"Drag-Enter akzeptiert: {len(xml_files)} XML-Dateien")
else:
event.ignore()
logger.debug("Drag-Enter ignoriert: Keine XML-Dateien")
else:
event.ignore()
logger.debug("Drag-Enter ignoriert: Keine URLs")
except Exception as e:
logger.error(f"Fehler in tree_drag_enter_event: {e}")
event.ignore()
def tree_drag_move_event(self, event):
"""
Wird ausgeführt, wenn ein Drag-Vorgang über das TreeWidget bewegt wird.
Args:
event: Das Drag-Move-Event
"""
try:
# Prüfe ob URLs (Dateien) gedraggt werden
if event.mimeData().hasUrls():
urls = event.mimeData().urls()
# Prüfe ob mindestens eine XML-Datei dabei ist
xml_files = [url.toLocalFile() for url in urls if url.toLocalFile().lower().endswith(".xml")]
if xml_files:
event.acceptProposedAction()
else:
event.ignore()
else:
event.ignore()
except Exception as e:
logger.error(f"Fehler in tree_drag_move_event: {e}")
event.ignore()
def tree_drop_event(self, event: QDropEvent):
"""
Wird ausgeführt, wenn Dateien auf das TreeWidget gedroppt werden.
Args:
event: Das Drop-Event
"""
try:
# Prüfe ob ein Projekt geladen ist
if not hasattr(self, "project") or not self.project:
QMessageBox.warning(self, "Warnung", "Kein Projekt geladen. Bitte öffnen Sie zuerst ein Projekt.")
event.ignore()
return
if not hasattr(self, "pdf_project") or not self.pdf_project:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Einstellungen geladen.")
event.ignore()
return
# Hole die URLs aus dem Drop-Event
if not event.mimeData().hasUrls():
event.ignore()
return
urls = event.mimeData().urls()
xml_files = []
# Sammle alle XML-Dateien
for url in urls:
file_path = url.toLocalFile()
if file_path.lower().endswith(".xml"):
xml_files.append(Path(file_path))
if not xml_files:
QMessageBox.information(self, "Information", "Keine XML-Dateien zum Hinzufügen gefunden.")
event.ignore()
return
logger.info(f"Drop-Event: {len(xml_files)} XML-Dateien erkannt")
# Verarbeite alle XML-Dateien mit optionalem "Alle zuordnen" Feature
self._handle_multiple_xml_files_drop(xml_files)
event.acceptProposedAction()
except Exception as e:
error_msg = f"Fehler beim Verarbeiten des Drop-Events: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
event.ignore()
def _handle_multiple_xml_files_drop(self, xml_files: list):
"""
Verarbeitet mehrere XML-Dateien asynchron per Drag&Drop.
Zeigt einen Dialog zur Auswahl der XSL-Knoten und startet dann die Batch-Verarbeitung im Hintergrund.
Args:
xml_files: Liste von Pfaden zu XML-Dateien
"""
if not xml_files:
return
try:
# Prüfe ob Projekt-Nodes verfügbar sind
if not self.pdf_project or not self.pdf_project.nodes:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Nodes verfügbar für die Zuordnung.")
return
# Zeige Dialog für die erste Datei
dialog = XmlToXslAssignDialog(parent=self, xml_file_path=xml_files[0], project_nodes=self.pdf_project.nodes)
if dialog.exec() != XmlToXslAssignDialog.DialogCode.Accepted:
logger.debug("Dialog abgebrochen - keine Dateien verarbeitet")
return
# Hole die ausgewählten XSL-Knoten
selected_xsl_nodes = dialog.get_selected_xsl_nodes()
if not selected_xsl_nodes:
logger.warning("Keine XSL-Knoten ausgewählt")
return
# Prüfe ob "Alle zuordnen" aktiviert wurde
apply_to_all = dialog.is_apply_to_all()
# Bestimme welche Dateien verarbeitet werden sollen
files_to_process = xml_files if apply_to_all else [xml_files[0]]
# Stoppe vorherigen Batch-Thread falls noch aktiv
if self.batch_processing_thread and self.batch_processing_thread.isRunning():
self.batch_processing_thread.quit()
self.batch_processing_thread.wait()
# Zusätzliche Sicherheitsprüfung für project_dir
if not self.project or not self.project.project_dir:
QMessageBox.warning(self, "Fehler", "Projekt-Verzeichnis ist nicht verfügbar")
return
# Erstelle und starte neuen Batch-Verarbeitungs-Thread
self.batch_processing_thread = XmlBatchProcessingThread(
xml_files=files_to_process,
selected_xsl_nodes=selected_xsl_nodes,
project_dir=Path(self.project.project_dir),
pdf_project=self.pdf_project,
)
# Verbinde Signale
self.batch_processing_thread.progress_update.connect(self._on_batch_progress_update)
self.batch_processing_thread.processing_finished.connect(self._on_batch_processing_finished)
# Zeige Progressbar
self._show_batch_progress_bar(len(files_to_process))
# Starte Thread
self.batch_processing_thread.start()
logger.info(
f"Batch-Verarbeitung von {len(files_to_process)} Datei(en) gestartet (apply_to_all={apply_to_all})"
)
except Exception as e:
error_msg = f"Fehler beim Starten der Batch-Verarbeitung: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _show_batch_progress_bar(self, total_files: int):
"""
Zeigt einen Progressbar in der Statusbar für die Batch-Verarbeitung.
Args:
total_files: Gesamtanzahl der zu verarbeitenden Dateien
"""
if self.batch_progress_bar is None:
self.batch_progress_bar = QProgressBar()
self.batch_progress_bar.setMaximumHeight(20)
self.batch_progress_bar.setMaximumWidth(300)
self.batch_progress_bar.setMinimum(0)
self.batch_progress_bar.setMaximum(total_files)
self.batch_progress_bar.setValue(0)
self.batch_progress_bar.setFormat("%v/%m Dateien")
# Füge Progressbar zur Statusbar hinzu
self.statusBar().addPermanentWidget(self.batch_progress_bar)
self.batch_progress_bar.show()
def _hide_batch_progress_bar(self):
"""Versteckt und entfernt den Progressbar aus der Statusbar."""
if self.batch_progress_bar:
self.statusBar().removeWidget(self.batch_progress_bar)
self.batch_progress_bar.hide()
def _on_batch_progress_update(self, current: int, total: int, current_file: str):
"""
Wird aufgerufen wenn der Batch-Thread einen Fortschritt meldet.
Args:
current: Aktuelle Dateinummer
total: Gesamtanzahl der Dateien
current_file: Name der aktuellen Datei
"""
if self.batch_progress_bar:
self.batch_progress_bar.setValue(current)
self.statusBar().showMessage(f"Verarbeite: {current_file} ({current}/{total})")
def _on_batch_processing_finished(self, stats: dict):
"""
Wird aufgerufen wenn die Batch-Verarbeitung abgeschlossen ist.
Args:
stats: Statistik-Dictionary mit Verarbeitungsergebnissen
"""
try:
# Verstecke Progressbar
self._hide_batch_progress_bar()
# Speichere Projekt-Einstellungen
if stats["processed"] > 0:
self._save_project_settings()
# Aktualisiere Tree
self._load_nodes_to_tree()
# Zeige Zusammenfassungsdialog
self._show_drop_summary_dialog(stats)
# Statusbar-Nachricht
self.statusBar().showMessage(
f"Batch-Verarbeitung abgeschlossen: {stats['processed']}/{stats['total']} Dateien", 5000
)
except Exception as e:
logger.error(f"Fehler beim Abschließen der Batch-Verarbeitung: {e}")
def _show_transformation_progress_bar(self, total_jobs: int):
"""
Zeigt einen Progressbar in der Statusbar für Transformationen.
Args:
total_jobs: Gesamtanzahl der Transformations-Jobs
"""
if self.transformation_progress_bar is None:
self.transformation_progress_bar = QProgressBar()
self.transformation_progress_bar.setMaximumHeight(20)
self.transformation_progress_bar.setMaximumWidth(300)
self.transformation_progress_bar.setMinimum(0)
self.transformation_progress_bar.setMaximum(total_jobs)
self.transformation_progress_bar.setValue(0)
self.transformation_progress_bar.setFormat("%v/%m Jobs")
# Füge Progressbar zur Statusbar hinzu
self.statusBar().addPermanentWidget(self.transformation_progress_bar)
self.transformation_progress_bar.show()
def _hide_transformation_progress_bar(self):
"""Versteckt und entfernt den Transformation-Progressbar aus der Statusbar."""
if self.transformation_progress_bar:
self.statusBar().removeWidget(self.transformation_progress_bar)
self.transformation_progress_bar.hide()
def _update_transformation_progress(self):
"""Aktualisiert den Transformation-Progressbar um einen Schritt."""
if self.transformation_progress_bar:
current_value = self.transformation_progress_bar.value()
self.transformation_progress_bar.setValue(current_value + 1)
def _show_drop_summary_dialog(self, stats: dict):
"""
Zeigt einen Zusammenfassungsdialog über die verarbeiteten XML-Dateien.
Args:
stats: Statistik-Dictionary mit Verarbeitungsergebnissen
"""
# Erstelle Zusammenfassungstext
summary_lines = []
summary_lines.append("Verarbeitung abgeschlossen:\n")
summary_lines.append(f"📊 Gesamt: {stats['total']} Datei(en)")
summary_lines.append(f"✓ Verarbeitet: {stats['processed']} Datei(en)")
if stats["new_added"] > 0:
summary_lines.append(f" Neu hinzugefügt: {stats['new_added']} Datei(en)")
if stats["existing_added"] > 0:
summary_lines.append(f"🔗 Vorhandene zugeordnet: {stats['existing_added']} Datei(en)")
if stats["already_assigned"] > 0:
summary_lines.append(f"️ Bereits zugeordnet: {stats['already_assigned']} Datei(en)")
if stats["cancelled"] > 0:
summary_lines.append(f"🚫 Abgebrochen: {stats['cancelled']} Datei(en)")
if stats["renamed_files"]:
summary_lines.append("\n📝 Umbenannte Dateien:")
for renamed in stats["renamed_files"]:
summary_lines.append(f"{renamed}")
if stats["errors"] > 0:
summary_lines.append(f"\n❌ Fehler: {stats['errors']}")
for error_msg in stats["error_messages"][:5]: # Zeige max. 5 Fehler
summary_lines.append(f"{error_msg}")
if len(stats["error_messages"]) > 5:
summary_lines.append(f" ... und {len(stats['error_messages']) - 5} weitere Fehler")
summary_text = "\n".join(summary_lines)
# Wähle Icon basierend auf Erfolg
if stats["errors"] > 0:
QMessageBox.warning(self, "Verarbeitung mit Fehlern abgeschlossen", summary_text)
elif stats["cancelled"] > 0:
QMessageBox.information(self, "Verarbeitung abgebrochen", summary_text)
else:
QMessageBox.information(self, "Verarbeitung erfolgreich", summary_text)
def _handle_xml_file_drop(self, xml_file_path: Path):
"""
Verarbeitet eine einzelne XML-Datei, die per Drag&Drop hinzugefügt wurde.
DEPRECATED: Diese Methode wird durch _handle_multiple_xml_files_drop ersetzt.
Args:
xml_file_path: Pfad zur XML-Datei
"""
try:
logger.debug(f"Verarbeite XML-Datei: {xml_file_path}")
# Prüfe ob die Datei existiert
if not xml_file_path.exists():
QMessageBox.critical(self, "Fehler", f"Die XML-Datei existiert nicht:\n{xml_file_path}")
return
# Prüfe ob Projekt-Nodes verfügbar sind
if not self.pdf_project or not self.pdf_project.nodes:
QMessageBox.warning(self, "Warnung", "Keine Projekt-Nodes verfügbar für die Zuordnung.")
return
# Öffne den Dialog zur Zuordnung zu XSL-Knoten
dialog = XmlToXslAssignDialog(
parent=self, xml_file_path=xml_file_path, project_nodes=self.pdf_project.nodes
)
if dialog.exec() == XmlToXslAssignDialog.DialogCode.Accepted:
# Hole die ausgewählten XSL-Knoten
selected_xsl_nodes = dialog.get_selected_xsl_nodes()
if selected_xsl_nodes:
# Verarbeite die Zuordnung
self._assign_xml_to_xsl_nodes(xml_file_path, selected_xsl_nodes)
else:
logger.warning("Keine XSL-Knoten ausgewählt")
else:
logger.debug("Dialog abgebrochen")
except Exception as e:
error_msg = f"Fehler beim Verarbeiten der XML-Datei '{xml_file_path}': {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _assign_xml_to_xsl_nodes(self, xml_file_path: Path, selected_xsl_nodes: list):
"""
Ordnet eine XML-Datei den ausgewählten XSL-Knoten zu.
Implementiert Hash-basierte Duplikatserkennung und intelligente Dateinamen-Verwaltung.
Args:
xml_file_path: Pfad zur XML-Datei
selected_xsl_nodes: Liste der ausgewählten XSL-Knoten
Returns:
dict: Statistiken über die Verarbeitung
"""
try:
logger.info(f"Ordne XML-Datei '{xml_file_path.name}' zu {len(selected_xsl_nodes)} XSL-Knoten zu")
# 1. Hash für die neue XML-Datei berechnen
file_hash = self._calculate_hash_for_file(xml_file_path)
if not file_hash:
logger.warning(f"Hash-Berechnung für {xml_file_path} fehlgeschlagen")
# 2. Prüfe ob eine XML-Datei mit gleichem Hash bereits im Projekt existiert
existing_xml = self._find_xml_file_by_hash(file_hash) if file_hash else None
if existing_xml:
# 3. Hash-Match gefunden: Ordne vorhandene XML-Datei zu
logger.info(f"Hash-Duplikat gefunden: {existing_xml.xml} hat gleichen Hash wie {xml_file_path.name}")
return self._assign_existing_xml_to_nodes(existing_xml, selected_xsl_nodes)
else:
# 4. Kein Hash-Match: Verarbeite als neue XML-Datei
logger.info(f"Keine Hash-Duplikate gefunden für {xml_file_path.name}, verarbeite als neue Datei")
return self._process_new_xml_file(xml_file_path, selected_xsl_nodes, file_hash)
except Exception as e:
error_msg = f"Fehler beim Zuordnen der XML-Datei: {str(e)}"
logger.error(error_msg)
return {"status": "error", "error_msg": error_msg}
def _start_xml_hash_calculation(self):
"""
Startet die asynchrone Hash-Berechnung für alle XML-Dateien im Projekt.
"""
try:
if not hasattr(self, "pdf_project") or not self.pdf_project:
logger.debug("Keine Projekt-Einstellungen verfügbar für Hash-Berechnung")
return
# Sammle alle XML-Dateien aus dem Projekt
xml_files = self._collect_all_xml_files()
if not xml_files:
logger.debug("Keine XML-Dateien für Hash-Berechnung gefunden")
return
logger.info(f"Starte Hash-Berechnung für {len(xml_files)} XML-Dateien")
# Prüfe ob Projekt verfügbar ist
if not self.project or not self.project.project_dir:
logger.warning("Kein Projekt-Verzeichnis für Hash-Berechnung verfügbar")
return
# Stoppe vorherigen Thread falls noch aktiv
if self.hash_calculator_thread and self.hash_calculator_thread.isRunning():
self.hash_calculator_thread.quit()
self.hash_calculator_thread.wait()
# Erstelle und starte neuen Hash-Berechnungs-Thread
self.hash_calculator_thread = XmlHashCalculatorThread(
project_dir=Path(self.project.project_dir), xml_files=xml_files
)
# Verbinde Signale
self.hash_calculator_thread.hash_calculated.connect(self._on_hash_calculated)
self.hash_calculator_thread.calculation_finished.connect(self._on_hash_calculation_finished)
self.hash_calculator_thread.error_occurred.connect(self._on_hash_calculation_error)
# Starte Thread
self.hash_calculator_thread.start()
except Exception as e:
logger.error(f"Fehler beim Starten der Hash-Berechnung: {e}")
def _collect_all_xml_files(self) -> List[XmlFile]:
"""
Sammelt alle XmlFile-Objekte aus der Projektstruktur.
Returns:
List[XmlFile]: Liste aller gefundenen XML-Dateien
"""
xml_files = []
try:
if self.pdf_project and self.pdf_project.nodes:
self._collect_xml_files_recursive(self.pdf_project.nodes, xml_files)
logger.debug(f"Gesammelt: {len(xml_files)} XML-Dateien")
return xml_files
except Exception as e:
logger.error(f"Fehler beim Sammeln der XML-Dateien: {e}")
return []
def _collect_xml_files_recursive(self, nodes, xml_files: List[XmlFile]):
"""
Sammelt rekursiv alle XML-Dateien aus den Nodes.
Args:
nodes: Liste der zu durchsuchenden Nodes
xml_files: Liste zum Sammeln der XML-Dateien
"""
for node in nodes:
if isinstance(node, XslFile) and node.xmls:
# Füge alle XML-Dateien dieser XSL-Datei hinzu
for xml_file in node.xmls:
if xml_file not in xml_files: # Vermeide Duplikate
xml_files.append(xml_file)
elif isinstance(node, TreeNode) and node.children:
# Rekursiv in Kinder-Nodes suchen
self._collect_xml_files_recursive(node.children, xml_files)
def _on_hash_calculated(self, xml_file: XmlFile, hash_value: str):
"""
Wird aufgerufen, wenn ein Hash-Wert berechnet wurde.
Args:
xml_file: Das XmlFile-Objekt
hash_value: Der berechnete Hash-Wert mit Präfix
"""
try:
# Setze den Hash-Wert
xml_file.hashsum = hash_value
logger.debug(f"Hash gesetzt für {xml_file.xml}: {hash_value}")
except Exception as e:
logger.error(f"Fehler beim Setzen des Hash-Werts: {e}")
def _on_hash_calculation_finished(self, processed_count: int, total_count: int):
"""
Wird aufgerufen, wenn die Hash-Berechnung abgeschlossen ist.
Args:
processed_count: Anzahl der verarbeiteten Dateien
total_count: Gesamtanzahl der Dateien
"""
try:
logger.info(f"Hash-Berechnung abgeschlossen: {processed_count}/{total_count} Dateien verarbeitet")
# Speichere die aktualisierten Projekt-Einstellungen
if processed_count > 0:
self._save_project_settings()
logger.info("Projekt-Einstellungen mit neuen Hash-Werten gespeichert")
except Exception as e:
logger.error(f"Fehler beim Abschließen der Hash-Berechnung: {e}")
def _on_hash_calculation_error(self, xml_file_path: str, error_message: str):
"""
Wird aufgerufen, wenn ein Fehler bei der Hash-Berechnung auftritt.
Args:
xml_file_path: Pfad zur XML-Datei
error_message: Fehlermeldung
"""
logger.warning(f"Hash-Berechnungsfehler für {xml_file_path}: {error_message}")
def _calculate_hash_for_xml_file(self, xml_file: XmlFile):
"""
Berechnet synchron den Hash für eine einzelne XML-Datei.
Wird verwendet beim Hinzufügen neuer XML-Dateien.
Args:
xml_file: Das XmlFile-Objekt
"""
try:
if xml_file.hashsum:
logger.debug(f"Hash bereits vorhanden für {xml_file.xml}: {xml_file.hashsum}")
return
# Prüfe ob Projekt verfügbar ist
if not self.project or not self.project.project_dir:
logger.warning("Kein Projekt-Verzeichnis für Hash-Berechnung verfügbar")
return
xml_file_path = Path(self.project.project_dir) / xml_file.xml
if not xml_file_path.exists():
logger.warning(f"XML-Datei nicht gefunden: {xml_file_path}")
return
# Datei binär lesen und Hash berechnen
with open(xml_file_path, "rb") as f:
file_content = f.read()
hash_obj = hashlib.blake2b(file_content)
hash_hex = hash_obj.hexdigest()
# Hash mit Präfix setzen
xml_file.hashsum = f"blake2b:{hash_hex}"
logger.debug(f"Hash berechnet für {xml_file.xml}: {xml_file.hashsum}")
except Exception as e:
logger.error(f"Fehler beim Berechnen des Hash für {xml_file.xml}: {e}")
def _get_all_project_xml_files(self) -> List[XmlFile]:
"""
Sammelt alle XmlFile-Objekte aus dem gesamten Projekt für Hash-Vergleiche.
Returns:
List[XmlFile]: Liste aller XML-Dateien im Projekt
"""
xml_files = []
try:
if self.pdf_project and self.pdf_project.nodes:
self._collect_xml_files_for_hash_comparison(self.pdf_project.nodes, xml_files)
logger.debug(f"Hash-Vergleich: {len(xml_files)} XML-Dateien im Projekt gefunden")
return xml_files
except Exception as e:
logger.error(f"Fehler beim Sammeln der XML-Dateien für Hash-Vergleich: {e}")
return []
def _collect_xml_files_for_hash_comparison(self, nodes, xml_files: List[XmlFile]):
"""
Sammelt rekursiv alle XML-Dateien aus den Nodes für Hash-Vergleiche.
Args:
nodes: Liste der zu durchsuchenden Nodes
xml_files: Liste zum Sammeln der XML-Dateien
"""
for node in nodes:
if isinstance(node, XslFile) and node.xmls:
# Füge alle XML-Dateien dieser XSL-Datei hinzu
for xml_file in node.xmls:
# Vermeide Duplikate basierend auf Pfad
if not any(existing.xml == xml_file.xml for existing in xml_files):
xml_files.append(xml_file)
elif isinstance(node, TreeNode) and node.children:
# Rekursiv in Kinder-Nodes suchen
self._collect_xml_files_for_hash_comparison(node.children, xml_files)
def _find_xml_file_by_hash(self, target_hash: str) -> XmlFile | None:
"""
Sucht eine XML-Datei mit dem angegebenen Hash im gesamten Projekt.
Args:
target_hash: Der zu suchende Hash-Wert (mit blake2b: Präfix)
Returns:
XmlFile|None: Die gefundene XML-Datei oder None
"""
try:
if not target_hash:
return None
all_xml_files = self._get_all_project_xml_files()
for xml_file in all_xml_files:
if xml_file.hashsum == target_hash:
logger.debug(f"Hash-Match gefunden: {xml_file.xml} hat Hash {target_hash}")
return xml_file
logger.debug(f"Kein Hash-Match für {target_hash} gefunden")
return None
except Exception as e:
logger.error(f"Fehler bei Hash-Suche für {target_hash}: {e}")
return None
def _generate_alternative_filename(self, original_path: Path, xml_dir: Path) -> Path:
"""
Generiert alternative Dateinamen im Format: datei_1.xml, datei_2.xml, ...
Args:
original_path: Ursprünglicher Dateipfad
xml_dir: Ziel-XML-Verzeichnis
Returns:
Path: Pfad mit alternativem Dateinamen
"""
try:
base_name = original_path.stem # "datei"
extension = original_path.suffix # ".xml"
# Sammle einmalig alle verwendeten Dateinamen (Performance-Optimierung)
all_xml_files = self._get_all_project_xml_files()
used_names = {xml_file.xml.name for xml_file in all_xml_files}
counter = 1
while True:
new_name = f"{base_name}_{counter}{extension}"
new_path = xml_dir / new_name
# Prüfe sowohl physische Existenz als auch Verwendung im Projekt (optimierter Set-Lookup)
if not new_path.exists() and new_name not in used_names:
logger.debug(f"Alternativer Dateiname generiert: {new_name}")
return new_path
counter += 1
# Sicherheitsgrenze um Endlosschleifen zu vermeiden
if counter > 1000:
raise Exception("Zu viele alternative Dateinamen generiert")
except Exception as e:
logger.error(f"Fehler beim Generieren alternativer Dateinamen für {original_path}: {e}")
# Fallback: Zeitstempel verwenden
timestamp = int(time.time())
fallback_name = f"{original_path.stem}_{timestamp}{original_path.suffix}"
return xml_dir / fallback_name
def _is_filename_used_in_project(self, relative_xml_path: Path) -> bool:
"""
Prüft ob ein relativer XML-Dateipfad bereits im Projekt verwendet wird.
Args:
relative_xml_path: Relativer Pfad zur XML-Datei (z.B. xml/datei_1.xml)
Returns:
bool: True wenn der Dateiname bereits verwendet wird
"""
try:
all_xml_files = self._get_all_project_xml_files()
for xml_file in all_xml_files:
if xml_file.xml == relative_xml_path:
return True
return False
except Exception as e:
logger.error(f"Fehler beim Prüfen der Dateiname-Verwendung für {relative_xml_path}: {e}")
return True # Im Zweifelsfall annehmen, dass der Name verwendet wird
def _calculate_hash_for_file(self, file_path: Path) -> str | None:
"""
Berechnet synchron den blake2b-Hash für eine Datei.
Args:
file_path: Pfad zur Datei
Returns:
str|None: Hash-Wert mit blake2b: Präfix oder None bei Fehler
"""
try:
if not file_path.exists():
logger.warning(f"Datei für Hash-Berechnung nicht gefunden: {file_path}")
return None
# Datei binär lesen und Hash berechnen
with open(file_path, "rb") as f:
file_content = f.read()
hash_obj = hashlib.blake2b(file_content)
hash_hex = hash_obj.hexdigest()
# Hash mit Präfix zurückgeben
hash_value = f"blake2b:{hash_hex}"
logger.debug(f"Hash berechnet für {file_path}: {hash_value}")
return hash_value
except Exception as e:
logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}")
return None
def _assign_existing_xml_to_nodes(self, existing_xml: XmlFile, selected_xsl_nodes: list):
"""
Ordnet eine bereits vorhandene XML-Datei (basierend auf Hash-Match) den XSL-Knoten zu.
Args:
existing_xml: Die bereits vorhandene XML-Datei
selected_xsl_nodes: Liste der ausgewählten XSL-Knoten
Returns:
dict: Statistiken mit 'status', 'added_count', 'existing_file'
"""
try:
added_count = 0
for xsl_node in selected_xsl_nodes:
# Prüfe ob diese XML-Datei bereits in der XslFile-Node vorhanden ist
already_assigned = any(xml_file.xml == existing_xml.xml for xml_file in xsl_node.xmls)
if not already_assigned:
# Erstelle neue XmlFile-Referenz mit gleichem Pfad und Hash
new_xml_ref = XmlFile(xml=existing_xml.xml, hashsum=existing_xml.hashsum)
xsl_node.xmls.append(new_xml_ref)
added_count += 1
logger.info(f"Vorhandene XML-Datei '{existing_xml.xml}' zu XSL-Node '{xsl_node.bez}' zugeordnet")
else:
logger.debug(f"XML-Datei '{existing_xml.xml}' bereits in XSL-Node '{xsl_node.bez}' vorhanden")
if added_count > 0:
# Speichere die aktualisierten Projekt-Einstellungen
self._save_project_settings()
# Aktualisiere das TreeWidget
self._load_nodes_to_tree()
return {
"status": "existing_added",
"added_count": added_count,
"existing_file": existing_xml.xml.name,
}
else:
return {"status": "already_assigned", "added_count": 0, "existing_file": existing_xml.xml.name}
except Exception as e:
error_msg = f"Fehler beim Zuordnen der vorhandenen XML-Datei: {str(e)}"
logger.error(error_msg)
return {"status": "error", "error_msg": error_msg}
def _process_new_xml_file(self, xml_file_path: Path, selected_xsl_nodes: list, file_hash: str | None):
"""
Verarbeitet eine neue XML-Datei (kein Hash-Match gefunden).
Args:
xml_file_path: Pfad zur neuen XML-Datei
selected_xsl_nodes: Liste der ausgewählten XSL-Knoten
file_hash: Berechneter Hash der Datei
Returns:
dict: Statistiken mit 'status', 'added_count', 'new_file', 'renamed_from'
"""
try:
# Prüfe ob Projekt verfügbar ist
if not self.project or not self.project.project_dir:
logger.error("Kein Projekt-Verzeichnis für neue XML-Datei verfügbar")
return {"status": "error", "error_msg": "Kein Projekt-Verzeichnis verfügbar."}
# Erstelle xml-Ordner im Projekt-Verzeichnis falls er nicht existiert
xml_dir = Path(self.project.project_dir) / "xml"
xml_dir.mkdir(parents=True, exist_ok=True)
# Bestimme den Ziel-Pfad in xml-Ordner
target_xml_path = xml_dir / xml_file_path.name
# Prüfe ob eine Datei mit gleichem Namen bereits existiert
if target_xml_path.exists() or self._is_filename_used_in_project(Path("xml") / xml_file_path.name):
# Generiere alternative Dateinamen
alternative_paths = []
for i in range(1, 6): # Generiere bis zu 5 Alternativen
alt_path = self._generate_alternative_filename(xml_file_path, xml_dir)
if alt_path not in alternative_paths:
alternative_paths.append(alt_path)
# Zeige Dialog zur Auswahl des Dateinamens
selected_path = self._show_filename_selection_dialog(xml_file_path.name, alternative_paths)
if not selected_path:
# Benutzer hat abgebrochen
return {"status": "cancelled", "added_count": 0}
target_xml_path = selected_path
# Kopiere die XML-Datei in den xml-Ordner
shutil.copy2(xml_file_path, target_xml_path)
logger.info(f"XML-Datei kopiert: {xml_file_path} -> {target_xml_path}")
# Erstelle relatives Path zur XML-Datei (relativ zum Projekt-Verzeichnis)
relative_xml_path = Path("xml") / target_xml_path.name
# Füge die XML-Datei zu allen ausgewählten XSL-Knoten hinzu
added_count = 0
for xsl_node in selected_xsl_nodes:
# Prüfe ob diese XML-Datei bereits in der XslFile-Node vorhanden ist
existing_xml = None
for xml_file in xsl_node.xmls:
if xml_file.xml == relative_xml_path:
existing_xml = xml_file
break
if not existing_xml:
# Erstelle neues XmlFile-Objekt mit Hash
new_xml_file = XmlFile(xml=relative_xml_path, hashsum=file_hash)
xsl_node.xmls.append(new_xml_file)
added_count += 1
logger.info(f"XML-Datei '{target_xml_path.name}' zu XSL-Node '{xsl_node.bez}' hinzugefügt")
else:
logger.debug(f"XML-Datei '{target_xml_path.name}' bereits in XSL-Node '{xsl_node.bez}' vorhanden")
if added_count > 0:
# Speichere die aktualisierten Projekt-Einstellungen
self._save_project_settings()
# Aktualisiere das TreeWidget
self._load_nodes_to_tree()
return {
"status": "new_added",
"added_count": added_count,
"new_file": target_xml_path.name,
"renamed_from": xml_file_path.name if target_xml_path.name != xml_file_path.name else None,
}
else:
return {
"status": "already_assigned",
"added_count": 0,
"new_file": target_xml_path.name,
}
except Exception as e:
error_msg = f"Fehler beim Verarbeiten der neuen XML-Datei: {str(e)}"
logger.error(error_msg)
return {"status": "error", "error_msg": error_msg}
def _show_filename_selection_dialog(self, original_name: str, alternative_paths: List[Path]) -> Path | None:
"""
Zeigt einen Dialog zur Auswahl eines alternativen Dateinamens.
Args:
original_name: Ursprünglicher Dateiname
alternative_paths: Liste alternativer Pfade
Returns:
Path|None: Ausgewählter Pfad oder None bei Abbruch
"""
try:
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QRadioButton,
QButtonGroup,
QPushButton,
QHBoxLayout,
)
dialog = QDialog(self)
dialog.setWindowTitle("Dateiname auswählen")
dialog.setModal(True)
dialog.resize(400, 300)
layout = QVBoxLayout(dialog)
# Erklärungstext
info_label = QLabel(
f"Eine Datei mit dem Namen '{original_name}' existiert bereits.\n\n"
"Bitte wählen Sie einen alternativen Dateinamen:"
)
layout.addWidget(info_label)
# Radio-Buttons für alternative Namen
button_group = QButtonGroup(dialog)
radio_buttons = []
for i, alt_path in enumerate(alternative_paths):
radio_button = QRadioButton(alt_path.name)
if i == 0: # Ersten als Standard auswählen
radio_button.setChecked(True)
button_group.addButton(radio_button, i)
radio_buttons.append(radio_button)
layout.addWidget(radio_button)
# Buttons
button_layout = QHBoxLayout()
ok_button = QPushButton("OK")
cancel_button = QPushButton("Abbrechen")
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Event-Handler
ok_button.clicked.connect(dialog.accept)
cancel_button.clicked.connect(dialog.reject)
# Dialog anzeigen
if dialog.exec() == QDialog.DialogCode.Accepted:
selected_id = button_group.checkedId()
if 0 <= selected_id < len(alternative_paths):
return alternative_paths[selected_id]
return None
except Exception as e:
logger.error(f"Fehler beim Anzeigen des Dateiname-Auswahl-Dialogs: {e}")
# Fallback: Ersten alternativen Namen verwenden
return alternative_paths[0] if alternative_paths else None
def _transform_xml_file(self, item: QTreeWidgetItem, force: bool = False):
"""
Transformiert eine einzelne XML-Datei.
Args:
item: Das TreeWidgetItem der XML-Datei
force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt
"""
try:
# Hole XslFile vom Parent-Item
parent_item = item.parent()
if not parent_item:
logger.error("XML-Datei hat kein Parent-Item (XslFile)")
QMessageBox.warning(self, "Fehler", "XML-Datei hat keine zugeordnete XSL-Datei")
return
xsl_file_obj = parent_item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(xsl_file_obj, XslFile):
logger.error(f"Parent-Item ist kein XslFile: {type(xsl_file_obj)}")
QMessageBox.warning(self, "Fehler", "Konnte XSL-Datei nicht ermitteln")
return
# Hole XmlFile-Objekt
xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(xml_file_obj, XmlFile):
logger.error(f"Item ist kein XmlFile: {type(xml_file_obj)}")
QMessageBox.warning(self, "Fehler", "Konnte XML-Datei nicht ermitteln")
return
# Erstelle TransformationJob mit TreeWidgetItem-Kontext für Parameter-Sammlung
job = self._create_transformation_job(xsl_file_obj, xml_file_obj, parent_item)
if not job:
return
# Starte Transformation in separatem Thread
self._start_transformation([job], force=force)
except Exception as e:
logger.error(f"Fehler beim Transformieren der XML-Datei: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}")
def _transform_xsl_file(self, item: QTreeWidgetItem, force: bool = False):
"""
Transformiert alle XML-Dateien einer XSL-Datei.
Args:
item: Das TreeWidgetItem der XSL-Datei
force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt
"""
try:
# Hole XslFile-Objekt
xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(xsl_file_obj, XslFile):
logger.error(f"Item ist kein XslFile: {type(xsl_file_obj)}")
QMessageBox.warning(self, "Fehler", "Konnte XSL-Datei nicht ermitteln")
return
# Prüfe ob XML-Dateien vorhanden sind
if not xsl_file_obj.xmls:
QMessageBox.information(self, "Info", "Keine XML-Dateien zugeordnet")
return
# Erstelle TransformationJobs für alle XML-Dateien
jobs = []
for xml_file_obj in xsl_file_obj.xmls:
# Übergebe das XslFile-TreeWidgetItem für Parameter-Sammlung
job = self._create_transformation_job(xsl_file_obj, xml_file_obj, item)
if job:
jobs.append(job)
if not jobs:
QMessageBox.warning(self, "Fehler", "Konnte keine Transformations-Jobs erstellen")
return
# Starte Transformation in separatem Thread
self._start_transformation(jobs, force=force)
except Exception as e:
logger.error(f"Fehler beim Transformieren der XSL-Datei: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}")
def _count_diff_pdfs_under_node(self, node: TreeNode | XslFile, node_item: QTreeWidgetItem) -> int:
"""
Zählt die Anzahl der existierenden Diff-PDFs unter einem Knoten.
Args:
node: TreeNode oder XslFile Objekt
node_item: Das TreeWidgetItem des Knotens
Returns:
int: Anzahl der existierenden Diff-PDF-Dateien
"""
count = 0
if isinstance(node, XslFile):
# Für XslFile: Zähle Diff-PDFs für jede XML-Datei
if not self.project:
return 0
diff_dir = self.project.project_dir / "diff"
xsl_id_str = "_".join(str(x) for x in node.id) if node.id else ""
for xml_file_obj in node.xmls:
xml_stem = xml_file_obj.xml.stem
pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf"
diff_pdf_path = diff_dir / pdf_basename
if diff_pdf_path.exists():
count += 1
elif isinstance(node, TreeNode):
# Für TreeNode: Rekursiv alle Kinder durchgehen
for i in range(node_item.childCount()):
child_item = node_item.child(i)
child_node = child_item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(child_node, (XslFile, TreeNode)):
count += self._count_diff_pdfs_under_node(child_node, child_item)
return count
def _update_diff_pdf_counts_recursive(self, tree_item: QTreeWidgetItem):
"""
Aktualisiert rekursiv die Diff-PDF-Anzahl in Spalte 2 für alle TreeNode und XslFile Items.
Args:
tree_item: Das TreeWidgetItem (kann Root oder beliebiger Knoten sein)
"""
node = tree_item.data(0, Qt.ItemDataRole.UserRole)
# Aktualisiere nur für TreeNode und XslFile, nicht für XmlFile
if isinstance(node, (TreeNode, XslFile)):
count = self._count_diff_pdfs_under_node(node, tree_item)
tree_item.setText(2, str(count) if count > 0 else "")
# Rekursiv für alle Kinder
for i in range(tree_item.childCount()):
child_item = tree_item.child(i)
self._update_diff_pdf_counts_recursive(child_item)
def _update_all_diff_pdf_counts(self):
"""
Aktualisiert die Diff-PDF-Anzahl für alle Knoten im TreeWidget.
"""
root = self.ui.treeWidget.invisibleRootItem()
for i in range(root.childCount()):
self._update_diff_pdf_counts_recursive(root.child(i))
def _has_xml_files_recursive(self, node: TreeNode) -> bool:
"""
Prüft rekursiv, ob unter einem TreeNode mindestens eine XML-Datei vorhanden ist.
Args:
node: Der TreeNode
Returns:
bool: True wenn mindestens eine XML-Datei gefunden wurde
"""
if not hasattr(node, "children") or not node.children:
return False
for child in node.children:
if isinstance(child, XslFile):
if child.xmls:
return True
elif isinstance(child, TreeNode):
if self._has_xml_files_recursive(child):
return True
return False
def _collect_all_xsl_xml_pairs_recursive(
self, tree_node: TreeNode, tree_item: QTreeWidgetItem
) -> list[tuple[XslFile, XmlFile, QTreeWidgetItem]]:
"""
Sammelt rekursiv alle (XslFile, XmlFile, XslFileItem) Tupel unter einem TreeNode.
Args:
tree_node: Der TreeNode
tree_item: Das TreeWidgetItem des TreeNode
Returns:
list: Liste von (XslFile, XmlFile, XslFileItem) Tupeln
"""
pairs = []
if not hasattr(tree_node, "children") or not tree_node.children:
return pairs
# Durchlaufe alle Kinder des TreeNode
for i in range(tree_item.childCount()):
child_item = tree_item.child(i)
child_node = child_item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(child_node, XslFile):
# XslFile gefunden - sammle alle XML-Dateien
for xml_file_obj in child_node.xmls:
pairs.append((child_node, xml_file_obj, child_item))
elif isinstance(child_node, TreeNode):
# Rekursiv in Unterknoten suchen
pairs.extend(self._collect_all_xsl_xml_pairs_recursive(child_node, child_item))
return pairs
def _transform_tree_node(self, item: QTreeWidgetItem, force: bool = False):
"""
Transformiert alle XML-Dateien unter einem TreeNode (rekursiv).
Args:
item: Das TreeWidgetItem des TreeNode
force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt
"""
try:
# Hole TreeNode-Objekt
tree_node_obj = item.data(0, Qt.ItemDataRole.UserRole)
if not isinstance(tree_node_obj, TreeNode):
logger.error(f"Item ist kein TreeNode: {type(tree_node_obj)}")
QMessageBox.warning(self, "Fehler", "Konnte TreeNode nicht ermitteln")
return
# Prüfe ob XML-Dateien vorhanden sind
if not self._has_xml_files_recursive(tree_node_obj):
QMessageBox.information(self, "Info", "Keine XML-Dateien unter diesem Knoten gefunden")
return
# Sammle alle XSL/XML-Paare rekursiv
xsl_xml_pairs = self._collect_all_xsl_xml_pairs_recursive(tree_node_obj, item)
if not xsl_xml_pairs:
QMessageBox.information(self, "Info", "Keine XML-Dateien gefunden")
return
# Erstelle TransformationJobs für alle XML-Dateien
jobs = []
for xsl_file_obj, xml_file_obj, xsl_file_item in xsl_xml_pairs:
# Übergebe das XslFile-TreeWidgetItem für Parameter-Sammlung
job = self._create_transformation_job(xsl_file_obj, xml_file_obj, xsl_file_item)
if job:
jobs.append(job)
if not jobs:
QMessageBox.warning(self, "Fehler", "Konnte keine Transformations-Jobs erstellen")
return
logger.info(f"Starte Transformation für {len(jobs)} XML-Dateien unter TreeNode '{tree_node_obj.bez}'")
# Starte Transformation in separatem Thread
self._start_transformation(jobs, force=force)
except Exception as e:
logger.error(f"Fehler beim Transformieren des TreeNode: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}")
def _create_transformation_job(
self, xsl_file_obj: XslFile, xml_file_obj: XmlFile, xsl_file_item: QTreeWidgetItem | None = None
) -> TransformationJob | None:
"""
Erstellt einen TransformationJob für eine XML/XSL-Kombination.
Args:
xsl_file_obj: Das XslFile-Objekt
xml_file_obj: Das XmlFile-Objekt
xsl_file_item: Optional das TreeWidgetItem des XslFile für hierarchische Parameter-Sammlung
Returns:
TransformationJob oder None bei Fehler
"""
try:
if not self.project:
QMessageBox.warning(self, "Fehler", "Kein Projekt geöffnet")
return None
# Hole Tool-Konfigurationen aus app_settings
java_vm = next((jvm for jvm in app_settings.java_vms if jvm.id == self.project.java_vm_id), None)
saxon_jar = next((sj for sj in app_settings.saxon_jars if sj.id == self.project.saxon_jar_id), None)
apache_fop = next((af for af in app_settings.apache_fops if af.id == self.project.apache_fop_id), None)
diff_pdf = next((dp for dp in app_settings.diff_pdfs if dp.id == self.project.diff_pdf_id), None)
xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None)
# Prüfe ob alle Konfigurationen vorhanden sind
if not all([java_vm, saxon_jar, apache_fop, diff_pdf, xsl_dir]):
missing = []
if not java_vm:
missing.append("Java VM")
if not saxon_jar:
missing.append("Saxon JAR")
if not apache_fop:
missing.append("Apache FOP")
if not diff_pdf:
missing.append("diff-pdf")
if not xsl_dir:
missing.append("XSL-Verzeichnis")
QMessageBox.warning(
self, "Fehlende Konfiguration", f"Folgende Konfigurationen fehlen: {', '.join(missing)}"
)
return None
# Zusätzliche Sicherheitsprüfung für path_to_binary_file Attribute
if java_vm is None or not hasattr(java_vm, "path_to_binary_file") or java_vm.path_to_binary_file is None:
QMessageBox.warning(self, "Konfigurationsfehler", "Java VM Pfad ist nicht konfiguriert")
return None
if saxon_jar is None or not hasattr(saxon_jar, "path_to_jar_file") or saxon_jar.path_to_jar_file is None:
QMessageBox.warning(self, "Konfigurationsfehler", "Saxon JAR Pfad ist nicht konfiguriert")
return None
if apache_fop is None or not hasattr(apache_fop, "path_to_dir") or apache_fop.path_to_dir is None:
QMessageBox.warning(self, "Konfigurationsfehler", "Apache FOP Pfad ist nicht konfiguriert")
return None
if diff_pdf is None or not hasattr(diff_pdf, "path_to_binary_file") or diff_pdf.path_to_binary_file is None:
QMessageBox.warning(self, "Konfigurationsfehler", "diff-pdf Pfad ist nicht konfiguriert")
return None
if xsl_dir is None or not hasattr(xsl_dir, "path_to_root_dir") or xsl_dir.path_to_root_dir is None:
QMessageBox.warning(self, "Konfigurationsfehler", "XSL-Verzeichnis Pfad ist nicht konfiguriert")
return None
# Erstelle absoluten Pfad zur XSL-Datei
xsl_file_abs = xsl_dir.path_to_root_dir / xsl_file_obj.xsl_file
# Sammle XSLT-Parameter hierarchisch (TreeNode-Eltern → XslFile)
xslt_params = {}
# 1. Sammle Parameter von übergeordneten TreeNodes (falls TreeWidgetItem verfügbar)
if xsl_file_item is not None:
parent_params = self._collect_parent_params(xsl_file_item)
xslt_params.update(parent_params)
logger.debug(f"Hierarchische Parameter gesammelt: {parent_params}")
else:
logger.warning(
"Kein TreeWidgetItem-Kontext verfügbar - "
"übergeordnete TreeNode-Parameter werden nicht berücksichtigt"
)
# 2. Überschreibe mit XslFile-eigenen Parametern (höchste Priorität)
xslt_params.update(xsl_file_obj.xslt_params)
logger.info(f"Finale XSLT-Parameter für {xml_file_obj.xml} mit {xsl_file_obj.bez}: {xslt_params}")
# Erstelle TransformationJob
job = TransformationJob(
project_dir=self.project.project_dir,
xml_file=xml_file_obj.xml,
xsl_file=xsl_file_abs,
xslt_params=xslt_params,
java_vm_path=java_vm.path_to_binary_file,
saxon_jar_path=saxon_jar.path_to_jar_file,
apache_fop_dir=apache_fop.path_to_dir,
diff_pdf_path=diff_pdf.path_to_binary_file,
diff_pdf_params=diff_pdf.default_params,
xsl_id=xsl_file_obj.id,
fop_config_dir=self.project.fop_config_dir,
)
return job
except Exception as e:
logger.error(f"Fehler beim Erstellen des TransformationJobs: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Erstellen des Jobs: {str(e)}")
return None
def _start_transformation(self, jobs: list[TransformationJob], force: bool = False):
"""
Startet die Transformation in einem separaten Thread.
Args:
jobs: Liste der TransformationJobs
force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date)
"""
try:
# Prüfe ob bereits ein Thread läuft
if self.transformation_thread and self.transformation_thread.isRunning():
QMessageBox.warning(self, "Warnung", "Es läuft bereits eine Transformation")
return
# Erstelle und konfiguriere Thread
self.transformation_thread = TransformationThread(jobs, force=force)
# Verbinde Signale
self.transformation_thread.job_started.connect(self._on_transformation_job_started)
self.transformation_thread.job_finished.connect(self._on_transformation_job_finished)
self.transformation_thread.job_error.connect(self._on_transformation_job_error)
self.transformation_thread.all_jobs_finished.connect(self._on_all_transformations_finished)
# Zeige Progressbar
self._show_transformation_progress_bar(len(jobs))
# Starte Thread
self.transformation_thread.start()
logger.info(f"Transformation von {len(jobs)} Job(s) gestartet (force={force})")
self.statusBar().showMessage(f"Transformation von {len(jobs)} Job(s) gestartet...")
except Exception as e:
logger.error(f"Fehler beim Starten der Transformation: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Starten: {str(e)}")
def _expand_tree_item_parents(self, item: QTreeWidgetItem):
"""
Öffnet alle Eltern-Knoten eines Tree-Items rekursiv.
Args:
item: Das Tree-Item, dessen Eltern geöffnet werden sollen
"""
if item is None:
return
# Rekursiv alle Eltern öffnen
parent = item.parent()
while parent is not None:
parent.setExpanded(True)
parent = parent.parent()
def _on_transformation_job_started(self, xml_file_name: str, xsl_id_str: str):
"""
Signal-Handler: Ein Job wurde gestartet.
Args:
xml_file_name: Name der XML-Datei
xsl_id_str: XSL-ID als String (z.B. "2002_1_128")
"""
logger.info(f"Transformation gestartet: {xml_file_name} (XSL-ID: {xsl_id_str})")
self.statusBar().showMessage(f"Transformiere: {xml_file_name}")
# Progress Bar anzeigen
map_key = f"{xml_file_name}|{xsl_id_str}"
if map_key not in self.xml_item_map and self.xml_item_map:
# Zeige erste Keys zur Diagnose
list(self.xml_item_map.keys())[:3]
logger.info(f"Suche TreeWidget-Item für: '{map_key}'")
logger.info(f"Map hat {len(self.xml_item_map)} Einträge")
tree_item = self.xml_item_map.get(map_key)
if tree_item:
# Öffne alle Eltern-Knoten, damit der Benutzer den Fortschritt sehen kann
self._expand_tree_item_parents(tree_item)
# Scrolle zum Item, damit es sichtbar ist
self.ui.treeWidget.scrollToItem(tree_item)
# Entferne vorhandenes Widget (falls Icon vorhanden)
self.ui.treeWidget.removeItemWidget(tree_item, 2)
# Erstelle und setze Progress Bar
progress_widget, progress_bar = self._create_centered_progress_bar()
self.ui.treeWidget.setItemWidget(tree_item, 2, progress_widget)
logger.debug(f"Progress Bar für {xml_file_name} gesetzt und Eltern-Knoten geöffnet")
else:
logger.warning(f"Kein TreeWidget-Item für {xml_file_name} gefunden")
def _on_transformation_job_finished(self, result: dict):
"""
Signal-Handler: Ein Job wurde abgeschlossen.
Args:
result: Ergebnis-Dictionary
"""
# Aktualisiere Transformation-Progressbar
self._update_transformation_progress()
xml_file = result.get("xml_file", "?")
success = result.get("success", False)
duration = result.get("duration", 0)
if success:
logger.info(f"Transformation erfolgreich: {xml_file} ({duration:.2f}s)")
pdfs_identical = result.get("pdfs_identical", False)
if pdfs_identical:
self.statusBar().showMessage(f"{xml_file} - PDFs identisch ({duration:.2f}s)", 3000)
else:
self.statusBar().showMessage(f"{xml_file} - Unterschiede gefunden ({duration:.2f}s)", 3000)
else:
logger.error(f"Transformation fehlgeschlagen: {xml_file}")
# Zeige Fehlerdetails an
steps = result.get("steps", {})
error_msgs = []
for step_name, step_info in steps.items():
if not step_info.get("success", True):
error_msgs.append(f"{step_name}: {step_info.get('message', 'Unbekannter Fehler')}")
error_text = "\n".join(error_msgs) if error_msgs else "Unbekannter Fehler"
QMessageBox.critical(
self, "Transformation fehlgeschlagen", f"XML-Datei: {xml_file}\n\nFehler:\n{error_text}"
)
# Update Widget in Spalte 2: Entferne Progress Bar, zeige Icon wenn Diff-PDF existiert
xml_file_str = result.get("xml_file", "")
xsl_id = result.get("xsl_id", None)
xsl_id_str = "_".join(str(x) for x in xsl_id) if xsl_id else ""
map_key = f"{xml_file_str}|{xsl_id_str}"
diff_pdf_str = result.get("diff_pdf", None)
tree_item = self.xml_item_map.get(map_key)
if tree_item:
# Entferne Progress Bar
self.ui.treeWidget.removeItemWidget(tree_item, 2)
# Wenn Diff-PDF existiert, zeige Icon
if diff_pdf_str and Path(diff_pdf_str).exists():
xml_file_path = Path(xml_file_str)
icon_widget = self._create_centered_diff_icon(xml_file_path, xsl_id_str)
self.ui.treeWidget.setItemWidget(tree_item, 2, icon_widget)
logger.debug(f"Diff-Icon für {xml_file_str} gesetzt")
else:
logger.debug(f"Keine Diff-PDF für {xml_file_str}, kein Icon gesetzt")
def _on_transformation_job_error(self, xml_file_name: str, xsl_id_str: str, error_message: str):
"""
Signal-Handler: Ein Job ist mit einem Fehler abgebrochen.
Args:
xml_file_name: Name der XML-Datei
xsl_id_str: XSL-ID als String
error_message: Fehlermeldung
"""
# Aktualisiere Transformation-Progressbar
self._update_transformation_progress()
logger.error(f"Transformation-Fehler bei {xml_file_name} (XSL-ID: {xsl_id_str}): {error_message}")
QMessageBox.critical(self, "Fehler", f"Fehler bei {xml_file_name}:\n{error_message}")
# Entferne Progress Bar bei Fehler
map_key = f"{xml_file_name}|{xsl_id_str}"
tree_item = self.xml_item_map.get(map_key)
if tree_item:
self.ui.treeWidget.removeItemWidget(tree_item, 2)
logger.debug(f"Progress Bar für {map_key} entfernt (Fehler)")
def _on_all_transformations_finished(self, successful_count: int, total_count: int, total_duration: float):
"""
Signal-Handler: Alle Jobs wurden abgeschlossen.
Args:
successful_count: Anzahl erfolgreicher Jobs
total_count: Gesamtanzahl der Jobs
total_duration: Gesamtdauer aller Transformationen in Sekunden
"""
logger.info(
f"Alle Transformationen abgeschlossen: {successful_count}/{total_count} erfolgreich ({total_duration:.2f}s)"
)
# Verstecke Transformation-Progressbar
self._hide_transformation_progress_bar()
# Aktualisiere Diff-PDF-Anzahl und Icons in allen Knoten
self._update_all_diff_pdf_counts()
self._update_diff_icons_for_existing_pdfs()
# Formatiere Dauer für Anzeige
duration_str = f"{total_duration:.2f}s"
if successful_count == total_count:
self.statusBar().showMessage(f"✓ Alle {total_count} Transformationen erfolgreich ({duration_str})", 5000)
QMessageBox.information(
self,
"Abgeschlossen",
f"Alle {total_count} Transformationen wurden erfolgreich abgeschlossen.\n\nGesamtdauer: {duration_str}",
)
else:
failed_count = total_count - successful_count
self.statusBar().showMessage(
f"{successful_count}/{total_count} erfolgreich, {failed_count} fehlgeschlagen ({duration_str})", 5000
)
QMessageBox.warning(
self,
"Abgeschlossen mit Fehlern",
f"{successful_count} von {total_count} Transformationen erfolgreich\n{failed_count} fehlgeschlagen\n\nGesamtdauer: {duration_str}",
)
def _collect_all_diff_pdfs_under_node(
self, node_obj, item: QTreeWidgetItem
) -> list[tuple[Path, str, Path, Path, Path]]:
"""
Sammelt alle Diff-PDFs unter einem TreeNode oder XslFile.
Args:
node_obj: TreeNode oder XslFile Objekt
item: Das TreeWidgetItem des Knotens
Returns:
list[tuple]: Liste von (xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path)
"""
diff_pdfs = []
if not self.project:
return diff_pdfs
diff_dir = self.project.project_dir / "diff"
ref_dir = self.project.project_dir / "ref"
new_dir = self.project.project_dir / "new"
if not diff_dir.exists():
return diff_pdfs
if isinstance(node_obj, TreeNode):
# Sammle alle XSL/XML-Paare rekursiv
xsl_xml_pairs = self._collect_all_xsl_xml_pairs_recursive(node_obj, item)
for xsl_file_obj, xml_file_obj, xsl_file_item in xsl_xml_pairs:
# Baue XML-Pfad auf
xml_file_path = self.project.project_dir / xml_file_obj.xml
# Baue PDF-Namen
xml_stem = xml_file_path.stem
xsl_id_str = "_".join(str(x) for x in xsl_file_obj.id) if xsl_file_obj.id else ""
pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf"
diff_pdf_path = diff_dir / pdf_basename
ref_pdf_path = ref_dir / pdf_basename
new_pdf_path = new_dir / pdf_basename
# Prüfe ob Diff-PDF existiert
if diff_pdf_path.exists():
diff_pdfs.append((xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path))
elif isinstance(node_obj, XslFile):
# Sammle alle XML-Dateien dieser XslFile
xsl_id_str = "_".join(str(x) for x in node_obj.id) if node_obj.id else ""
for xml_file_obj in node_obj.xmls:
# Baue XML-Pfad auf
xml_file_path = self.project.project_dir / xml_file_obj.xml
# Baue PDF-Namen
xml_stem = xml_file_path.stem
pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf"
diff_pdf_path = diff_dir / pdf_basename
ref_pdf_path = ref_dir / pdf_basename
new_pdf_path = new_dir / pdf_basename
# Prüfe ob Diff-PDF existiert
if diff_pdf_path.exists():
diff_pdfs.append((xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path))
return diff_pdfs
def _find_next_diff_pdf(self) -> tuple[Path, str] | None:
"""
Findet die nächste Diff-PDF im Projekt.
Returns:
tuple[Path, str] | None: (xml_file_path, xsl_id_str) der nächsten Diff-PDF oder None
"""
if not self.project:
return None
diff_dir = self.project.project_dir / "diff"
if not diff_dir.exists():
return None
# Durchlaufe xml_item_map und prüfe welche Items Diff-PDFs haben
for map_key, tree_item in self.xml_item_map.items():
# Map-Key hat Format "xml_path|xsl_id"
parts = map_key.split("|")
if len(parts) != 2:
continue
xml_path_str = parts[0]
xsl_id_str = parts[1]
# Konvertiere zu Path
xml_file_path = Path(xml_path_str)
# Prüfe ob Diff-PDF existiert
xml_stem = xml_file_path.stem
pdf_basename = f"{xml_stem}_xsl_{xsl_id_str}.pdf"
diff_pdf_path = diff_dir / pdf_basename
if diff_pdf_path.exists():
return (xml_file_path, xsl_id_str)
return None
def _accept_single_diff_pdf(
self, xml_file_path: Path, xsl_id_str: str, diff_pdf_path: Path, ref_pdf_path: Path, new_pdf_path: Path
) -> bool:
"""
Akzeptiert eine einzelne Diff-PDF ohne Viewer-Update.
Args:
xml_file_path: Pfad zur XML-Datei
xsl_id_str: XSL-ID als String
diff_pdf_path: Pfad zur Diff-PDF
ref_pdf_path: Pfad zur Ref-PDF
new_pdf_path: Pfad zur New-PDF
Returns:
bool: True wenn erfolgreich, False bei Fehler
"""
pdf_basename = diff_pdf_path.name # Initialisiere am Anfang für Exception-Handler
try:
# Prüfe ob new-PDF existiert
if not new_pdf_path.exists():
logger.warning(f"New-PDF nicht gefunden: {pdf_basename}")
return False
# Lösche alte ref-PDF falls vorhanden
if ref_pdf_path.exists():
ref_pdf_path.unlink()
logger.debug(f"Alte Ref-PDF gelöscht: {ref_pdf_path}")
# Verschiebe new-PDF nach ref
shutil.move(str(new_pdf_path), str(ref_pdf_path))
logger.debug(f"New-PDF verschoben nach Ref: {new_pdf_path} -> {ref_pdf_path}")
# Lösche diff-PDF
if diff_pdf_path.exists():
diff_pdf_path.unlink()
logger.debug(f"Diff-PDF gelöscht: {diff_pdf_path}")
# Diff-Icon beim XML-Knoten entfernen
# WICHTIG: xml_item_map verwendet relative Pfade, nicht absolute!
if self.project and self.project.project_dir:
xml_relative_path = xml_file_path.relative_to(self.project.project_dir)
else:
# Fallback: Verwende absoluten Pfad als String
xml_relative_path = xml_file_path
map_key = f"{xml_relative_path}|{xsl_id_str}"
if map_key in self.xml_item_map:
tree_item = self.xml_item_map[map_key]
# Entferne Icon-Widget aus Spalte 2
tree_item.setData(2, Qt.ItemDataRole.UserRole, None)
self.ui.treeWidget.setItemWidget(tree_item, 2, None)
logger.info(f"Änderungen akzeptiert für: {pdf_basename}")
return True
except Exception as e:
logger.error(f"Fehler beim Akzeptieren von {pdf_basename}: {e}")
return False
def _accept_all_changes_under_node(self, item: QTreeWidgetItem):
"""
Akzeptiert alle Diff-PDFs unter einem TreeNode oder XslFile.
Leert den Viewer, falls eine der akzeptierten PDFs gerade angezeigt wird.
Args:
item: Das TreeWidgetItem des TreeNode oder XslFile
"""
try:
# Hole Node-Objekt
node_obj = item.data(0, Qt.ItemDataRole.UserRole)
if not node_obj:
logger.warning("Kein Node-Objekt gefunden")
return
# Sammle alle Diff-PDFs unter diesem Knoten
diff_pdfs = self._collect_all_diff_pdfs_under_node(node_obj, item)
if not diff_pdfs:
QMessageBox.information(self, "Info", "Keine Diff-PDFs unter diesem Knoten gefunden")
return
# Frage Benutzer um Bestätigung
node_name = node_obj.bez if hasattr(node_obj, "bez") else "Unbekannt"
reply = QMessageBox.question(
self,
"Alle Änderungen übernehmen",
f"Möchten Sie wirklich alle {len(diff_pdfs)} Änderungen unter '{node_name}' übernehmen?\n\n"
f"Dies verschiebt alle new-PDFs nach ref und löscht die diff-PDFs.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
# Merke, ob eine der zu akzeptierenden PDFs gerade im Viewer angezeigt wird
viewer_needs_clearing = False
if self.current_diff_xml_path and self.current_diff_xsl_id:
for xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path in diff_pdfs:
if xml_file_path == self.current_diff_xml_path and xsl_id_str == self.current_diff_xsl_id:
viewer_needs_clearing = True
break
# Akzeptiere alle Diff-PDFs
successful_count = 0
for xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path in diff_pdfs:
if self._accept_single_diff_pdf(xml_file_path, xsl_id_str, diff_pdf_path, ref_pdf_path, new_pdf_path):
successful_count += 1
# Aktualisiere Diff-PDF-Anzahl auf übergeordneten Ebenen
self._update_all_diff_pdf_counts()
# Leere Viewer falls nötig
if viewer_needs_clearing:
self._clear_pdf_viewer()
# Zeige Erfolgsmeldung
if successful_count == len(diff_pdfs):
logger.info(f"Alle {successful_count} Änderungen erfolgreich übernommen")
QMessageBox.information(
self, "Erfolg", f"Alle {successful_count} Änderungen wurden erfolgreich übernommen"
)
else:
failed_count = len(diff_pdfs) - successful_count
logger.warning(
f"{successful_count}/{len(diff_pdfs)} Änderungen übernommen, {failed_count} fehlgeschlagen"
)
QMessageBox.warning(
self,
"Teilweise erfolgreich",
f"{successful_count} von {len(diff_pdfs)} Änderungen übernommen\n{failed_count} fehlgeschlagen",
)
except Exception as e:
logger.error(f"Fehler beim Übernehmen aller Änderungen: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Übernehmen der Änderungen:\n{str(e)}")
def _close_all_pdf_documents(self):
"""Schließt alle geöffneten PDF-Dokumente explizit (wichtig für Windows)."""
import gc
if self.pdf_documents:
for pdf_basename, docs in self.pdf_documents.items():
for doc_type, doc in docs.items():
if doc:
doc.close()
logger.debug(f"PDF-Dokument geschlossen: {pdf_basename} ({doc_type})")
# Lösche alle Referenzen
self.pdf_documents.clear()
# Lösche gerenderte Pixmaps
self.current_rendered_pixmaps = None
# Erzwinge Garbage Collection um Dateihandles freizugeben (wichtig für Windows)
gc.collect()
logger.info("Alle PDF-Dokumente geschlossen und Referenzen freigegeben")
def _clear_pdf_viewer(self):
"""Leert den PDF-Viewer und alle Thumbnails."""
# Schließe alle PDF-Dokumente explizit (wichtig für Windows)
self._close_all_pdf_documents()
# Entferne Widgets aus Layouts
self._clear_layout(self.ui.verticalLayout_2)
self._clear_layout(self.ui.verticalLayout_3)
# Zurücksetzen der Datenstrukturen
self.thumbnail_to_page = {}
self.pdf_documents = {}
self.current_rendered_pixmaps = None
self.fullsize_label = None
self.current_pdf = None
self.current_diff_xml_path = None
self.current_diff_xsl_id = None
logger.info("PDF-Viewer geleert")
def _on_accept_changes_clicked(self):
"""
Handler für Accept-Changes-Button.
Verschiebt new PDF nach ref, löscht diff PDF, aktualisiert Icons und lädt nächste Diff-PDF.
"""
try:
if not self.project or not self.current_diff_xml_path or not self.current_diff_xsl_id:
logger.warning("Keine aktuelle Diff-PDF zum Akzeptieren")
return
# PDF-Dateinamen ermitteln
xml_stem = self.current_diff_xml_path.stem
pdf_basename = f"{xml_stem}_xsl_{self.current_diff_xsl_id}.pdf"
# Pfade
diff_dir = self.project.project_dir / "diff"
ref_dir = self.project.project_dir / "ref"
new_dir = self.project.project_dir / "new"
diff_pdf_path = diff_dir / pdf_basename
ref_pdf_path = ref_dir / pdf_basename
new_pdf_path = new_dir / pdf_basename
logger.info(f"Akzeptiere Änderungen für: {pdf_basename}")
# Prüfe ob new-PDF existiert
if not new_pdf_path.exists():
QMessageBox.warning(self, "Fehler", f"New-PDF nicht gefunden:\n{pdf_basename}")
return
# Schließe alle PDF-Dokumente und leere UI VOR dem Löschen/Verschieben (wichtig für Windows)
self._close_all_pdf_documents()
# Entferne auch alle Widgets, die Pixmaps enthalten könnten
self._clear_layout(self.ui.verticalLayout_2)
self._clear_layout(self.ui.verticalLayout_3)
self.thumbnail_to_page = {}
self.fullsize_label = None
# Verarbeite alle pending Qt Events um sicherzustellen, dass Widgets/Ressourcen freigegeben werden
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
logger.info("PDF-Dokumente geschlossen und UI geleert vor Dateioperationen")
# Lösche alte ref-PDF falls vorhanden
if ref_pdf_path.exists():
ref_pdf_path.unlink()
logger.info(f"Alte Ref-PDF gelöscht: {ref_pdf_path}")
# Verschiebe new-PDF nach ref
shutil.move(str(new_pdf_path), str(ref_pdf_path))
logger.info(f"New-PDF verschoben nach Ref: {new_pdf_path} -> {ref_pdf_path}")
# Lösche diff-PDF
if diff_pdf_path.exists():
diff_pdf_path.unlink()
logger.info(f"Diff-PDF gelöscht: {diff_pdf_path}")
# Diff-Icon beim aktuellen XML-Knoten entfernen
map_key = f"{self.current_diff_xml_path}|{self.current_diff_xsl_id}"
if map_key in self.xml_item_map:
tree_item = self.xml_item_map[map_key]
# Entferne Icon-Widget aus Spalte 2
tree_item.setData(2, Qt.ItemDataRole.UserRole, None)
self.ui.treeWidget.setItemWidget(tree_item, 2, None)
logger.info(f"Diff-Icon entfernt für: {map_key}")
# Diff-PDF-Anzahl auf übergeordneten Ebenen aktualisieren
self._update_all_diff_pdf_counts()
# Finde nächste Diff-PDF
next_diff = self._find_next_diff_pdf()
if next_diff:
# Lade nächste Diff-PDF
next_xml_path, next_xsl_id = next_diff
logger.info(f"Lade nächste Diff-PDF: {next_xml_path} / {next_xsl_id}")
self._load_pdf_for_comparison(next_xml_path, next_xsl_id)
# Wähle den entsprechenden XML-Knoten im Baum aus
map_key = f"{next_xml_path}|{next_xsl_id}"
if map_key in self.xml_item_map:
tree_item = self.xml_item_map[map_key]
self.ui.treeWidget.setCurrentItem(tree_item)
self.ui.treeWidget.scrollToItem(tree_item)
logger.info(f"TreeWidget-Item ausgewählt: {map_key}")
else:
# Keine weiteren Diff-PDFs, Button deaktivieren und Viewer leeren
logger.info("Keine weiteren Diff-PDFs vorhanden")
self.ui.accept_changes.setEnabled(False)
self._clear_pdf_viewer()
except Exception as e:
logger.error(f"Fehler beim Akzeptieren der Änderungen: {e}")
QMessageBox.critical(self, "Fehler", f"Fehler beim Akzeptieren der Änderungen:\n{str(e)}")
def closeEvent(self, event):
"""Wird beim Schließen der Anwendung aufgerufen."""
# UI-Zustände speichern
self._save_ui_state()
# Stoppe Hash-Berechnungs-Thread falls noch aktiv
if (
hasattr(self, "hash_calculator_thread")
and self.hash_calculator_thread
and self.hash_calculator_thread.isRunning()
):
self.hash_calculator_thread.quit()
self.hash_calculator_thread.wait()
# Stoppe Transformations-Thread falls noch aktiv
if (
hasattr(self, "transformation_thread")
and self.transformation_thread
and self.transformation_thread.isRunning()
):
self.transformation_thread.quit()
self.transformation_thread.wait()
# PDF-Dokumente schließen ist bei QtPdf automatisch durch Garbage Collection
super().closeEvent(event)
def _save_ui_state(self):
"""Speichert die aktuellen UI-Zustände (Fenstergeometrie, Splitter, TreeWidget-Spalten)."""
global app_settings
# Fenstergeometrie speichern
geometry = self.geometry()
app_settings.window_geometry = (geometry.x(), geometry.y(), geometry.width(), geometry.height())
# Splitter-Positionen speichern
app_settings.splitter_sizes = self.ui.splitter.sizes()
# TreeWidget-Spaltenbreiten speichern
column_count = self.ui.treeWidget.columnCount()
app_settings.tree_column_widths = [self.ui.treeWidget.columnWidth(i) for i in range(column_count)]
# Konfiguration speichern
app_settings.save()