Performance-Verbesserung: Asynchrone Batch-Verarbeitung und Progressbars

Änderungen:
- XML-Batch-Processing in separaten Thread verlagert (XmlBatchProcessingThread)
  • Verhindert UI-Freezing bei vielen XML-Dateien
  • Hash-Berechnung, Duplikatserkennung und Dateikopieren asynchron
  • Live Progress-Updates an Hauptthread
- Progressbar für XML-Batch-Verarbeitung in Statusbar
  • Zeigt "X/Y Dateien" während Verarbeitung
  • Aktueller Dateiname in Statusbar-Text
  • Automatisches Verstecken nach Abschluss
- Progressbar für Transformationen in Statusbar hinzugefügt
  • Zeigt "X/Y Jobs" während Transformation
  • Live-Update nach jedem abgeschlossenen Job
  • Funktioniert bei Erfolg und Fehlern
- Zusammenfassungsdialog am Ende statt einzelner Erfolgsdialoge

Technische Details:
- Neue Thread-Klasse mit Signalen für Progress-Updates
- Progressbar-Management-Methoden für beide Operationen
- Signal-Handler angepasst für Live-Updates
- Statistik-Sammlung für detaillierten Zusammenfassungsdialog

UX-Verbesserungen:
- UI bleibt während Verarbeitung reaktionsfähig
- Benutzer sieht Gesamtfortschritt in Echtzeit
- Kein wiederholtes Klicken auf OK-Dialoge mehr
- Transparente Anzeige laufender Operationen

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 20:31:54 +01:00
parent e4b2272e61
commit e7408eac7c
+417 -80
View File
@@ -123,6 +123,262 @@ class XmlHashCalculatorThread(QThread):
return None 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): class TransformationThread(QThread):
""" """
Thread für die asynchrone Ausführung von Transformations-Jobs. Thread für die asynchrone Ausführung von Transformations-Jobs.
@@ -235,6 +491,15 @@ class MainWindow(QMainWindow):
# Transformations-Thread # Transformations-Thread
self.transformation_thread = None 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) # Mapping: xml_file_path_str → QTreeWidgetItem (für Progress Bar und Icon Updates)
self.xml_item_map = {} self.xml_item_map = {}
@@ -2440,8 +2705,8 @@ class MainWindow(QMainWindow):
def _handle_multiple_xml_files_drop(self, xml_files: list): def _handle_multiple_xml_files_drop(self, xml_files: list):
""" """
Verarbeitet mehrere XML-Dateien, die per Drag&Drop hinzugefügt wurden. Verarbeitet mehrere XML-Dateien asynchron per Drag&Drop.
Unterstützt das "Alle XML-Dateien zuordnen" Feature. Zeigt einen Dialog zur Auswahl der XSL-Knoten und startet dann die Batch-Verarbeitung im Hintergrund.
Args: Args:
xml_files: Liste von Pfaden zu XML-Dateien xml_files: Liste von Pfaden zu XML-Dateien
@@ -2455,102 +2720,162 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "Warnung", "Keine Projekt-Nodes verfügbar für die Zuordnung.") QMessageBox.warning(self, "Warnung", "Keine Projekt-Nodes verfügbar für die Zuordnung.")
return return
# Variablen für "Alle zuordnen" Feature # Zeige Dialog für die erste Datei
apply_to_all = False dialog = XmlToXslAssignDialog(
cached_selected_nodes = None parent=self, xml_file_path=xml_files[0], project_nodes=self.pdf_project.nodes
)
# Statistiken für Zusammenfassung if dialog.exec() != XmlToXslAssignDialog.DialogCode.Accepted:
stats = { logger.debug("Dialog abgebrochen - keine Dateien verarbeitet")
"total": len(xml_files), return
"processed": 0,
"new_added": 0,
"existing_added": 0,
"already_assigned": 0,
"cancelled": 0,
"errors": 0,
"error_messages": [],
"renamed_files": [],
}
# Verarbeite jede XML-Datei # Hole die ausgewählten XSL-Knoten
for i, xml_file_path in enumerate(xml_files): selected_xsl_nodes = dialog.get_selected_xsl_nodes()
logger.debug(f"Verarbeite XML-Datei {i+1}/{len(xml_files)}: {xml_file_path}")
# Prüfe ob die Datei existiert if not selected_xsl_nodes:
if not xml_file_path.exists(): logger.warning("Keine XSL-Knoten ausgewählt")
stats["errors"] += 1 return
stats["error_messages"].append(f"{xml_file_path.name}: Datei existiert nicht")
continue
# Wenn "Alle zuordnen" aktiv ist und wir bereits eine Auswahl haben # Prüfe ob "Alle zuordnen" aktiviert wurde
if apply_to_all and cached_selected_nodes: apply_to_all = dialog.is_apply_to_all()
# Verwende die gecachte Auswahl
result = self._assign_xml_to_xsl_nodes(xml_file_path, cached_selected_nodes)
self._update_stats(stats, result)
continue
# Zeige den Dialog für die erste Datei oder wenn "Alle zuordnen" nicht aktiv ist # Bestimme welche Dateien verarbeitet werden sollen
dialog = XmlToXslAssignDialog( files_to_process = xml_files if apply_to_all else [xml_files[0]]
parent=self, xml_file_path=xml_file_path, project_nodes=self.pdf_project.nodes
)
if dialog.exec() == XmlToXslAssignDialog.DialogCode.Accepted: # Stoppe vorherigen Batch-Thread falls noch aktiv
# Hole die ausgewählten XSL-Knoten if self.batch_processing_thread and self.batch_processing_thread.isRunning():
selected_xsl_nodes = dialog.get_selected_xsl_nodes() self.batch_processing_thread.quit()
self.batch_processing_thread.wait()
if selected_xsl_nodes: # Erstelle und starte neuen Batch-Verarbeitungs-Thread
# Verarbeite die Zuordnung self.batch_processing_thread = XmlBatchProcessingThread(
result = self._assign_xml_to_xsl_nodes(xml_file_path, selected_xsl_nodes) xml_files=files_to_process,
self._update_stats(stats, result) selected_xsl_nodes=selected_xsl_nodes,
project_dir=Path(self.project.project_dir),
pdf_project=self.pdf_project,
)
# Prüfe ob "Alle zuordnen" aktiviert wurde # Verbinde Signale
if dialog.is_apply_to_all() and i < len(xml_files) - 1: self.batch_processing_thread.progress_update.connect(self._on_batch_progress_update)
# Es gibt noch weitere Dateien und User möchte alle zuordnen self.batch_processing_thread.processing_finished.connect(self._on_batch_processing_finished)
apply_to_all = True
cached_selected_nodes = selected_xsl_nodes # Zeige Progressbar
logger.info( self._show_batch_progress_bar(len(files_to_process))
f"'Alle zuordnen' aktiviert - {len(xml_files) - i - 1} weitere Datei(en) werden automatisch zugeordnet"
) # Starte Thread
else: self.batch_processing_thread.start()
logger.warning("Keine XSL-Knoten ausgewählt")
else: logger.info(
# Dialog wurde abgebrochen - beende die Verarbeitung f"Batch-Verarbeitung von {len(files_to_process)} Datei(en) gestartet (apply_to_all={apply_to_all})"
stats["cancelled"] = len(xml_files) - i )
logger.debug(f"Dialog abgebrochen - {stats['cancelled']} Datei(en) übersprungen")
break 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 # Zeige Zusammenfassungsdialog
self._show_drop_summary_dialog(stats) self._show_drop_summary_dialog(stats)
except Exception as e: # Statusbar-Nachricht
error_msg = f"Fehler beim Verarbeiten der XML-Dateien: {str(e)}" self.statusBar().showMessage(
logger.error(error_msg) f"Batch-Verarbeitung abgeschlossen: {stats['processed']}/{stats['total']} Dateien", 5000
QMessageBox.critical(self, "Fehler", error_msg) )
def _update_stats(self, stats: dict, result: dict): except Exception as e:
logger.error(f"Fehler beim Abschließen der Batch-Verarbeitung: {e}")
def _show_transformation_progress_bar(self, total_jobs: int):
""" """
Aktualisiert die Statistiken basierend auf dem Verarbeitungsergebnis. Zeigt einen Progressbar in der Statusbar für Transformationen.
Args: Args:
stats: Statistik-Dictionary total_jobs: Gesamtanzahl der Transformations-Jobs
result: Ergebnis von _assign_xml_to_xsl_nodes
""" """
stats["processed"] += 1 if self.transformation_progress_bar is None:
self.transformation_progress_bar = QProgressBar()
self.transformation_progress_bar.setMaximumHeight(20)
self.transformation_progress_bar.setMaximumWidth(300)
status = result.get("status") self.transformation_progress_bar.setMinimum(0)
if status == "new_added": self.transformation_progress_bar.setMaximum(total_jobs)
stats["new_added"] += 1 self.transformation_progress_bar.setValue(0)
if result.get("renamed_from"): self.transformation_progress_bar.setFormat("%v/%m Jobs")
stats["renamed_files"].append(f"{result['renamed_from']}{result['new_file']}")
elif status == "existing_added": # Füge Progressbar zur Statusbar hinzu
stats["existing_added"] += 1 self.statusBar().addPermanentWidget(self.transformation_progress_bar)
elif status == "already_assigned": self.transformation_progress_bar.show()
stats["already_assigned"] += 1
elif status == "cancelled": def _hide_transformation_progress_bar(self):
stats["cancelled"] += 1 """Versteckt und entfernt den Transformation-Progressbar aus der Statusbar."""
elif status == "error": if self.transformation_progress_bar:
stats["errors"] += 1 self.statusBar().removeWidget(self.transformation_progress_bar)
stats["error_messages"].append(result.get("error_msg", "Unbekannter Fehler")) 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): def _show_drop_summary_dialog(self, stats: dict):
""" """
@@ -3609,6 +3934,9 @@ class MainWindow(QMainWindow):
self.transformation_thread.job_error.connect(self._on_transformation_job_error) self.transformation_thread.job_error.connect(self._on_transformation_job_error)
self.transformation_thread.all_jobs_finished.connect(self._on_all_transformations_finished) self.transformation_thread.all_jobs_finished.connect(self._on_all_transformations_finished)
# Zeige Progressbar
self._show_transformation_progress_bar(len(jobs))
# Starte Thread # Starte Thread
self.transformation_thread.start() self.transformation_thread.start()
@@ -3679,6 +4007,9 @@ class MainWindow(QMainWindow):
Args: Args:
result: Ergebnis-Dictionary result: Ergebnis-Dictionary
""" """
# Aktualisiere Transformation-Progressbar
self._update_transformation_progress()
xml_file = result.get("xml_file", "?") xml_file = result.get("xml_file", "?")
success = result.get("success", False) success = result.get("success", False)
duration = result.get("duration", 0) duration = result.get("duration", 0)
@@ -3734,6 +4065,9 @@ class MainWindow(QMainWindow):
xsl_id_str: XSL-ID als String xsl_id_str: XSL-ID als String
error_message: Fehlermeldung 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}") 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}") QMessageBox.critical(self, "Fehler", f"Fehler bei {xml_file_name}:\n{error_message}")
@@ -3754,6 +4088,9 @@ class MainWindow(QMainWindow):
""" """
logger.info(f"Alle Transformationen abgeschlossen: {successful_count}/{total_count} erfolgreich") logger.info(f"Alle Transformationen abgeschlossen: {successful_count}/{total_count} erfolgreich")
# Verstecke Transformation-Progressbar
self._hide_transformation_progress_bar()
# Aktualisiere Diff-PDF-Anzahl und Icons in allen Knoten # Aktualisiere Diff-PDF-Anzahl und Icons in allen Knoten
self._update_all_diff_pdf_counts() self._update_all_diff_pdf_counts()
self._update_diff_icons_for_existing_pdfs() self._update_diff_icons_for_existing_pdfs()