Hash-basierte XML-Duplikatserkennung und intelligente Dateinamen-Verwaltung

Implementiert automatische Erkennung von XML-Datei-Duplikaten basierend auf blake2b-Hashes. Bei Hash-Match wird die vorhandene Datei automatisch zugeordnet statt sie zu kopieren. Bei Dateinamen-Konflikten werden alternative Namen (datei_1.xml, datei_2.xml, etc.) mit Auswahl-Dialog angeboten.

Neue Features:
- Projekt-weite Hash-Duplikatserkennung
- Automatische Zuordnung vorhandener Dateien bei Hash-Match
- Alternative Dateinamen-Generierung mit Benutzer-Dialog
- Performance-Optimierung durch Set-basierte Dateinamen-Prüfung
- Umfassende Dokumentation und Test-Suite

🤖 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-07 20:15:38 +01:00
parent 0417996db2
commit d314cf5612
3 changed files with 950 additions and 71 deletions
+378 -71
View File
@@ -2026,90 +2026,35 @@ class MainWindow(QMainWindow):
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
"""
try:
print(f"Ordne XML-Datei '{xml_file_path.name}' zu {len(selected_xsl_nodes)} XSL-Knoten zu")
logger.info(f"Ordne XML-Datei '{xml_file_path.name}' zu {len(selected_xsl_nodes)} XSL-Knoten zu")
# 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)
# 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")
# Bestimme den Ziel-Pfad in xml-Ordner
target_xml_path = xml_dir / xml_file_path.name
# 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
# Prüfe ob eine Datei mit gleichem Namen bereits existiert
copy_file = True
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:
copy_file = False
# Kopiere die XML-Datei in den xml-Ordner (falls gewünscht)
if copy_file:
shutil.copy2(xml_file_path, target_xml_path)
print(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
# 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 und füge es zur XslFile-Node hinzu
new_xml_file = XmlFile(xml=relative_xml_path)
# Berechne Hash für die neue XML-Datei
self._calculate_hash_for_xml_file(new_xml_file)
xsl_node.xmls.append(new_xml_file)
added_count += 1
print(f"XML-Datei '{xml_file_path.name}' zu XslFile-Node '{xsl_node.bez}' hinzugefügt")
else:
print(f"XML-Datei '{xml_file_path.name}' bereits in XslFile-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()
# Zeige Erfolgsmeldung
QMessageBox.information(
self,
"Erfolg",
f"XML-Datei '{xml_file_path.name}' wurde erfolgreich zu {added_count} XSL-Knoten hinzugefügt."
)
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}")
self._assign_existing_xml_to_nodes(existing_xml, selected_xsl_nodes)
else:
QMessageBox.information(
self,
"Information",
f"XML-Datei '{xml_file_path.name}' war bereits in allen ausgewählten XSL-Knoten vorhanden."
)
# 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")
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)}"
print(error_msg)
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", error_msg)
def _start_xml_hash_calculation(self):
@@ -2267,6 +2212,368 @@ class MainWindow(QMainWindow):
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
"""
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()
# Zeige Erfolgsmeldung
QMessageBox.information(
self,
"XML-Datei zugeordnet",
f"Eine XML-Datei mit gleichem Inhalt war bereits im Projekt vorhanden.\n\n"
f"Die vorhandene Datei '{existing_xml.xml.name}' wurde automatisch zu {added_count} XSL-Knoten zugeordnet."
)
else:
QMessageBox.information(
self,
"Bereits zugeordnet",
f"Die XML-Datei mit gleichem Inhalt ist bereits in allen ausgewählten XSL-Knoten vorhanden."
)
except Exception as e:
error_msg = f"Fehler beim Zuordnen der vorhandenen XML-Datei: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", 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
"""
try:
# 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
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()
# Zeige Erfolgsmeldung
success_msg = f"XML-Datei '{target_xml_path.name}' wurde erfolgreich zu {added_count} XSL-Knoten hinzugefügt."
if target_xml_path.name != xml_file_path.name:
success_msg += f"\n\nDie Datei wurde umbenannt von '{xml_file_path.name}' zu '{target_xml_path.name}'."
QMessageBox.information(self, "Erfolg", success_msg)
else:
QMessageBox.information(
self,
"Information",
f"XML-Datei '{target_xml_path.name}' war bereits in allen ausgewählten XSL-Knoten vorhanden."
)
except Exception as e:
error_msg = f"Fehler beim Verarbeiten der neuen XML-Datei: {str(e)}"
logger.error(error_msg)
QMessageBox.critical(self, "Fehler", 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 closeEvent(self, event):
"""Wird beim Schließen der Anwendung aufgerufen."""
# Stoppe Hash-Berechnungs-Thread falls noch aktiv