Files
xsl-validator/src/ui/mixins/hash_calculation.py
T

584 lines
24 KiB
Python
Raw Normal View History

"""
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