diff --git a/src/ui/MainWindow.py b/src/ui/MainWindow.py index 921aa07..c7bf710 100644 --- a/src/ui/MainWindow.py +++ b/src/ui/MainWindow.py @@ -123,6 +123,262 @@ class XmlHashCalculatorThread(QThread): 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. @@ -235,6 +491,15 @@ class MainWindow(QMainWindow): # 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 = {} @@ -2440,8 +2705,8 @@ class MainWindow(QMainWindow): def _handle_multiple_xml_files_drop(self, xml_files: list): """ - Verarbeitet mehrere XML-Dateien, die per Drag&Drop hinzugefügt wurden. - Unterstützt das "Alle XML-Dateien zuordnen" Feature. + 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 @@ -2455,102 +2720,162 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "Warnung", "Keine Projekt-Nodes verfügbar für die Zuordnung.") return - # Variablen für "Alle zuordnen" Feature - apply_to_all = False - cached_selected_nodes = None + # Zeige Dialog für die erste Datei + dialog = XmlToXslAssignDialog( + parent=self, xml_file_path=xml_files[0], project_nodes=self.pdf_project.nodes + ) - # Statistiken für Zusammenfassung - stats = { - "total": len(xml_files), - "processed": 0, - "new_added": 0, - "existing_added": 0, - "already_assigned": 0, - "cancelled": 0, - "errors": 0, - "error_messages": [], - "renamed_files": [], - } + if dialog.exec() != XmlToXslAssignDialog.DialogCode.Accepted: + logger.debug("Dialog abgebrochen - keine Dateien verarbeitet") + return - # Verarbeite jede XML-Datei - for i, xml_file_path in enumerate(xml_files): - logger.debug(f"Verarbeite XML-Datei {i+1}/{len(xml_files)}: {xml_file_path}") + # Hole die ausgewählten XSL-Knoten + selected_xsl_nodes = dialog.get_selected_xsl_nodes() - # Prüfe ob die Datei existiert - if not xml_file_path.exists(): - stats["errors"] += 1 - stats["error_messages"].append(f"{xml_file_path.name}: Datei existiert nicht") - continue + if not selected_xsl_nodes: + logger.warning("Keine XSL-Knoten ausgewählt") + return - # Wenn "Alle zuordnen" aktiv ist und wir bereits eine Auswahl haben - if apply_to_all and cached_selected_nodes: - # Verwende die gecachte Auswahl - result = self._assign_xml_to_xsl_nodes(xml_file_path, cached_selected_nodes) - self._update_stats(stats, result) - continue + # Prüfe ob "Alle zuordnen" aktiviert wurde + apply_to_all = dialog.is_apply_to_all() - # Zeige den Dialog für die erste Datei oder wenn "Alle zuordnen" nicht aktiv ist - dialog = XmlToXslAssignDialog( - parent=self, xml_file_path=xml_file_path, project_nodes=self.pdf_project.nodes - ) + # Bestimme welche Dateien verarbeitet werden sollen + files_to_process = xml_files if apply_to_all else [xml_files[0]] - if dialog.exec() == XmlToXslAssignDialog.DialogCode.Accepted: - # Hole die ausgewählten XSL-Knoten - selected_xsl_nodes = dialog.get_selected_xsl_nodes() + # 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() - if selected_xsl_nodes: - # Verarbeite die Zuordnung - result = self._assign_xml_to_xsl_nodes(xml_file_path, selected_xsl_nodes) - self._update_stats(stats, result) + # 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, + ) - # Prüfe ob "Alle zuordnen" aktiviert wurde - if dialog.is_apply_to_all() and i < len(xml_files) - 1: - # Es gibt noch weitere Dateien und User möchte alle zuordnen - apply_to_all = True - cached_selected_nodes = selected_xsl_nodes - logger.info( - f"'Alle zuordnen' aktiviert - {len(xml_files) - i - 1} weitere Datei(en) werden automatisch zugeordnet" - ) - else: - logger.warning("Keine XSL-Knoten ausgewählt") - else: - # Dialog wurde abgebrochen - beende die Verarbeitung - stats["cancelled"] = len(xml_files) - i - logger.debug(f"Dialog abgebrochen - {stats['cancelled']} Datei(en) übersprungen") - break + # 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) - except Exception as e: - error_msg = f"Fehler beim Verarbeiten der XML-Dateien: {str(e)}" - logger.error(error_msg) - QMessageBox.critical(self, "Fehler", error_msg) + # Statusbar-Nachricht + self.statusBar().showMessage( + f"Batch-Verarbeitung abgeschlossen: {stats['processed']}/{stats['total']} Dateien", 5000 + ) - 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: - stats: Statistik-Dictionary - result: Ergebnis von _assign_xml_to_xsl_nodes + total_jobs: Gesamtanzahl der Transformations-Jobs """ - 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") - if status == "new_added": - stats["new_added"] += 1 - if result.get("renamed_from"): - stats["renamed_files"].append(f"{result['renamed_from']} → {result['new_file']}") - elif status == "existing_added": - stats["existing_added"] += 1 - elif status == "already_assigned": - stats["already_assigned"] += 1 - elif status == "cancelled": - stats["cancelled"] += 1 - elif status == "error": - stats["errors"] += 1 - stats["error_messages"].append(result.get("error_msg", "Unbekannter Fehler")) + 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): """ @@ -3609,6 +3934,9 @@ class MainWindow(QMainWindow): 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() @@ -3679,6 +4007,9 @@ class MainWindow(QMainWindow): 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) @@ -3734,6 +4065,9 @@ class MainWindow(QMainWindow): 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}") @@ -3754,6 +4088,9 @@ class MainWindow(QMainWindow): """ 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 self._update_all_diff_pdf_counts() self._update_diff_icons_for_existing_pdfs()