""" HashCalculationMixin - Mixin für Hash-Berechnung und XML-Dateiverwaltung. Dieses Mixin enthält alle Methoden zur blake2b-Hash-Berechnung, XML-Datei-Zuordnung und Duplikatserkennung für das MainWindow. """ import shutil import time import logging from pathlib import Path from typing import List from PySide6.QtWidgets import QMessageBox from conf import TreeNode, XslFile, XmlFile from ui.XmlToXslAssignDialog import XmlToXslAssignDialog from ui.threads import XmlHashCalculatorThread from utils import calculate_blake2b_hash logger = logging.getLogger(__name__) class HashCalculationMixin: """ Mixin für Hash-Berechnung und XML-Dateiverwaltung. Dieses Mixin wird von MainWindow verwendet und erwartet folgende Attribute: - self.project: Das aktuelle Projekt - self.pdf_project: Die Projekt-Daten (ProjectData) - self.hash_calculator_thread: Thread für Hash-Berechnung - self._save_project_settings(): Methode zum Speichern der Projekt-Einstellungen - self._load_nodes_to_tree(): Methode zum Laden der Nodes in den Tree """ def _handle_xml_file_drop(self, xml_file_path: Path): """ Verarbeitet eine einzelne XML-Datei, die per Drag&Drop hinzugefügt wurde. DEPRECATED: Diese Methode wird durch _handle_multiple_xml_files_drop ersetzt. Args: xml_file_path: Pfad zur XML-Datei """ try: logger.debug(f"Verarbeite XML-Datei: {xml_file_path}") # Prüfe ob die Datei existiert if not xml_file_path.exists(): QMessageBox.critical(self, "Fehler", f"Die XML-Datei existiert nicht:\n{xml_file_path}") return # Prüfe ob Projekt-Nodes verfügbar sind if not self.pdf_project or not self.pdf_project.nodes: QMessageBox.warning(self, "Warnung", "Keine Projekt-Nodes verfügbar für die Zuordnung.") return # Öffne den Dialog zur Zuordnung zu XSL-Knoten dialog = XmlToXslAssignDialog( parent=self, xml_file_path=xml_file_path, project_nodes=self.pdf_project.nodes ) if dialog.exec() == XmlToXslAssignDialog.DialogCode.Accepted: # Hole die ausgewählten XSL-Knoten selected_xsl_nodes = dialog.get_selected_xsl_nodes() if selected_xsl_nodes: # Verarbeite die Zuordnung self._assign_xml_to_xsl_nodes(xml_file_path, selected_xsl_nodes) else: logger.warning("Keine XSL-Knoten ausgewählt") else: logger.debug("Dialog abgebrochen") except Exception as e: error_msg = f"Fehler beim Verarbeiten der XML-Datei '{xml_file_path}': {str(e)}" logger.error(error_msg) QMessageBox.critical(self, "Fehler", error_msg) def _assign_xml_to_xsl_nodes(self, xml_file_path: Path, selected_xsl_nodes: list): """ Ordnet eine XML-Datei den ausgewählten XSL-Knoten zu. Implementiert Hash-basierte Duplikatserkennung und intelligente Dateinamen-Verwaltung. Args: xml_file_path: Pfad zur XML-Datei selected_xsl_nodes: Liste der ausgewählten XSL-Knoten Returns: dict: Statistiken über die Verarbeitung """ try: logger.info(f"Ordne XML-Datei '{xml_file_path.name}' zu {len(selected_xsl_nodes)} XSL-Knoten zu") # 1. Hash für die neue XML-Datei berechnen file_hash = self._calculate_hash_for_file(xml_file_path) if not file_hash: logger.warning(f"Hash-Berechnung für {xml_file_path} fehlgeschlagen") # 2. Prüfe ob eine XML-Datei mit gleichem Hash bereits im Projekt existiert existing_xml = self._find_xml_file_by_hash(file_hash) if file_hash else None if existing_xml: # 3. Hash-Match gefunden: Ordne vorhandene XML-Datei zu logger.info(f"Hash-Duplikat gefunden: {existing_xml.xml} hat gleichen Hash wie {xml_file_path.name}") return self._assign_existing_xml_to_nodes(existing_xml, selected_xsl_nodes) else: # 4. Kein Hash-Match: Verarbeite als neue XML-Datei logger.info(f"Keine Hash-Duplikate gefunden für {xml_file_path.name}, verarbeite als neue Datei") return self._process_new_xml_file(xml_file_path, selected_xsl_nodes, file_hash) except Exception as e: error_msg = f"Fehler beim Zuordnen der XML-Datei: {str(e)}" logger.error(error_msg) return {"status": "error", "error_msg": error_msg} def _start_xml_hash_calculation(self): """ Startet die asynchrone Hash-Berechnung für alle XML-Dateien im Projekt. """ try: if not hasattr(self, "pdf_project") or not self.pdf_project: logger.debug("Keine Projekt-Einstellungen verfügbar für Hash-Berechnung") return # Sammle alle XML-Dateien aus dem Projekt xml_files = self._collect_all_xml_files() if not xml_files: logger.debug("Keine XML-Dateien für Hash-Berechnung gefunden") return logger.info(f"Starte Hash-Berechnung für {len(xml_files)} XML-Dateien") # Prüfe ob Projekt verfügbar ist if not self.project or not self.project.project_dir: logger.warning("Kein Projekt-Verzeichnis für Hash-Berechnung verfügbar") return # Stoppe vorherigen Thread falls noch aktiv if self.hash_calculator_thread and self.hash_calculator_thread.isRunning(): self.hash_calculator_thread.quit() self.hash_calculator_thread.wait() # Erstelle und starte neuen Hash-Berechnungs-Thread self.hash_calculator_thread = XmlHashCalculatorThread( project_dir=Path(self.project.project_dir), xml_files=xml_files ) # Verbinde Signale self.hash_calculator_thread.hash_calculated.connect(self._on_hash_calculated) self.hash_calculator_thread.calculation_finished.connect(self._on_hash_calculation_finished) self.hash_calculator_thread.error_occurred.connect(self._on_hash_calculation_error) # Starte Thread self.hash_calculator_thread.start() except Exception as e: logger.error(f"Fehler beim Starten der Hash-Berechnung: {e}") def _collect_all_xml_files(self) -> List[XmlFile]: """ Sammelt alle XmlFile-Objekte aus der Projektstruktur. Returns: List[XmlFile]: Liste aller gefundenen XML-Dateien """ xml_files = [] try: if self.pdf_project and self.pdf_project.nodes: self._collect_xml_files_recursive(self.pdf_project.nodes, xml_files) logger.debug(f"Gesammelt: {len(xml_files)} XML-Dateien") return xml_files except Exception as e: logger.error(f"Fehler beim Sammeln der XML-Dateien: {e}") return [] def _collect_xml_files_recursive(self, nodes, xml_files: List[XmlFile]): """ Sammelt rekursiv alle XML-Dateien aus den Nodes. Args: nodes: Liste der zu durchsuchenden Nodes xml_files: Liste zum Sammeln der XML-Dateien """ seen_paths = {xf.xml for xf in xml_files} for node in nodes: if isinstance(node, XslFile) and node.xmls: for xml_file in node.xmls: if xml_file.xml not in seen_paths: xml_files.append(xml_file) seen_paths.add(xml_file.xml) elif isinstance(node, TreeNode) and node.children: 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 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 hash_value = calculate_blake2b_hash(xml_file_path) if hash_value: xml_file.hashsum = hash_value 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.""" return self._collect_all_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.""" return calculate_blake2b_hash(file_path) 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