""" XslDependencyDialog - Dialog zur Anzeige des XSL-Abhängigkeitsgraphen. Zeigt alle XSL-Dateien im konfigurierten Verzeichnis mit ihren Import/Include-Beziehungen (vorwärts und rückwärts). Bietet zwei Ansichten: Baumansicht und interaktiver Netzwerkgraph (vis.js). """ import json import logging import tempfile from pathlib import Path from PySide6.QtCore import QUrl, Qt from PySide6.QtGui import QIcon from PySide6.QtWidgets import QDialog, QTreeWidgetItem from ui.XslDependencyDialog_ui import Ui_XslDependencyDialog from xsl_dependencies import XslDependencyGraph logger = logging.getLogger(__name__) # Prüfe ob QWebEngineView verfügbar ist try: from PySide6.QtWebEngineWidgets import QWebEngineView HAS_WEBENGINE = True except ImportError: HAS_WEBENGINE = False logger.warning("PySide6-WebEngine nicht verfügbar — Netzwerkgraph-Tab deaktiviert") class XslDependencyDialog(QDialog): """Dialog zur Anzeige des vollständigen XSL-Abhängigkeitsgraphen.""" def __init__(self, parent, xsl_root_dir: Path, dependency_graph: XslDependencyGraph): """ Args: parent: Eltern-Widget xsl_root_dir: Wurzelverzeichnis der XSL-Dateien dependency_graph: XSL-Abhängigkeitsgraph-Instanz """ super().__init__(parent) self.xsl_root_dir = xsl_root_dir self.dependency_graph = dependency_graph self._web_view: "QWebEngineView | None" = None self._graph_loaded = False self._temp_html_file: tempfile.NamedTemporaryFile | None = None self.ui = Ui_XslDependencyDialog() self.ui.setupUi(self) # Netzwerkgraph-Tab ausblenden wenn WebEngine nicht verfügbar if not HAS_WEBENGINE: self.ui.tabWidget.removeTab(1) # Spalten konfigurieren self.ui.fileTree.setHeaderLabels(["XSL-Datei", "Imports", "Importiert von"]) self.ui.fileTree.setColumnWidth(0, 300) self.ui.fileTree.setColumnWidth(1, 60) self.ui.fileTree.setColumnWidth(2, 60) self.ui.depTree.setHeaderLabels(["Datei", "Typ"]) self.ui.depTree.setColumnWidth(0, 350) # Graph aufbauen und anzeigen self._full_graph = self.dependency_graph.build_full_graph(self.xsl_root_dir) self._reverse_map = self._build_reverse_map() self._populate_file_tree() # Signale verbinden self.ui.fileTree.currentItemChanged.connect(self._on_file_selected) self.ui.searchEdit.textChanged.connect(self._on_search_changed) self.ui.tabWidget.currentChanged.connect(self._on_tab_changed) def closeEvent(self, event): """Räumt temporäre Dateien und QWebEngineView auf.""" if self._web_view is not None: self._web_view.setUrl(QUrl("about:blank")) self._web_view.deleteLater() self._web_view = None if self._temp_html_file is not None: try: Path(self._temp_html_file.name).unlink(missing_ok=True) except Exception: pass self._temp_html_file = None super().closeEvent(event) def _build_reverse_map(self) -> dict[Path, set[Path]]: """Baut eine Reverse-Map auf: Welche Dateien importieren eine gegebene Datei?""" reverse: dict[Path, set[Path]] = {} for xsl_file, deps in self._full_graph.items(): for dep in deps: if dep not in reverse: reverse[dep] = set() reverse[dep].add(xsl_file) return reverse def _rel_path(self, abs_path: Path) -> str: """Gibt den relativen Pfad zur XSL-Root zurück.""" try: return str(abs_path.relative_to(self.xsl_root_dir)) except ValueError: return abs_path.name def _populate_file_tree(self): """Befüllt den Dateibaum mit allen XSL-Dateien und Abhängigkeitszahlen.""" self.ui.fileTree.clear() xsl_icon = QIcon.fromTheme("text-x-generic") for xsl_file in sorted(self._full_graph.keys(), key=lambda p: self._rel_path(p).lower()): deps_count = len(self._full_graph.get(xsl_file, set())) reverse_count = len(self._reverse_map.get(xsl_file, set())) item = QTreeWidgetItem() item.setText(0, self._rel_path(xsl_file)) item.setText(1, str(deps_count) if deps_count > 0 else "") item.setText(2, str(reverse_count) if reverse_count > 0 else "") item.setData(0, Qt.ItemDataRole.UserRole, xsl_file) item.setIcon(0, xsl_icon) # Hervorhebung für Dateien mit vielen Abhängigkeiten if deps_count > 5 or reverse_count > 10: font = item.font(0) font.setBold(True) item.setFont(0, font) self.ui.fileTree.addTopLevelItem(item) total = len(self._full_graph) with_deps = sum(1 for deps in self._full_graph.values() if deps) self.ui.statusLabel.setText(f"{total} XSL-Dateien, davon {with_deps} mit Abhängigkeiten") def _on_file_selected(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None): """Zeigt Abhängigkeitsdetails für die ausgewählte Datei.""" self.ui.depTree.clear() if current is None: self.ui.rightLabel.setText("Abhängigkeiten") return xsl_file: Path = current.data(0, Qt.ItemDataRole.UserRole) if xsl_file is None: return rel_name = self._rel_path(xsl_file) self.ui.rightLabel.setText(f"Abhängigkeiten: {rel_name}") import_icon = QIcon.fromTheme("go-down") imported_by_icon = QIcon.fromTheme("go-up") # Sektion: "Importiert" (forward dependencies) deps = self._full_graph.get(xsl_file, set()) if deps: imports_root = QTreeWidgetItem() imports_root.setText(0, f"Importiert ({len(deps)})") imports_root.setIcon(0, import_icon) font = imports_root.font(0) font.setBold(True) imports_root.setFont(0, font) for dep in sorted(deps, key=lambda p: self._rel_path(p).lower()): dep_item = QTreeWidgetItem() dep_item.setText(0, self._rel_path(dep)) dep_item.setText(1, "import/include") dep_item.setData(0, Qt.ItemDataRole.UserRole, dep) imports_root.addChild(dep_item) self.ui.depTree.addTopLevelItem(imports_root) imports_root.setExpanded(True) # Sektion: "Wird importiert von" (reverse dependencies) reverse_deps = self._reverse_map.get(xsl_file, set()) if reverse_deps: imported_by_root = QTreeWidgetItem() imported_by_root.setText(0, f"Wird importiert von ({len(reverse_deps)})") imported_by_root.setIcon(0, imported_by_icon) font = imported_by_root.font(0) font.setBold(True) imported_by_root.setFont(0, font) for rev in sorted(reverse_deps, key=lambda p: self._rel_path(p).lower()): rev_item = QTreeWidgetItem() rev_item.setText(0, self._rel_path(rev)) rev_item.setText(1, "importiert diese Datei") rev_item.setData(0, Qt.ItemDataRole.UserRole, rev) imported_by_root.addChild(rev_item) self.ui.depTree.addTopLevelItem(imported_by_root) imported_by_root.setExpanded(True) if not deps and not reverse_deps: no_deps_item = QTreeWidgetItem() no_deps_item.setText(0, "Keine Abhängigkeiten") self.ui.depTree.addTopLevelItem(no_deps_item) def _on_search_changed(self, text: str): """Filtert die Dateiliste nach dem Suchbegriff.""" search_lower = text.lower() for i in range(self.ui.fileTree.topLevelItemCount()): item = self.ui.fileTree.topLevelItem(i) matches = search_lower in item.text(0).lower() item.setHidden(not matches) # === Netzwerkgraph (vis.js) === def _on_tab_changed(self, index: int): """Lazy-Init des Netzwerkgraphs beim ersten Tab-Wechsel.""" if not HAS_WEBENGINE: return # Tab 1 = Netzwerkgraph if index == 1 and not self._graph_loaded: self._setup_network_graph() def _setup_network_graph(self): """Erstellt QWebEngineView und lädt den vis.js Netzwerkgraph.""" self._web_view = QWebEngineView(self.ui.graphContainer) self.ui.graphContainerLayout.addWidget(self._web_view) # Graph-Daten aufbauen (nur direkte Abhängigkeiten für Kanten) nodes_json, edges_json = self._build_graph_data() html = self._generate_html(nodes_json, edges_json) # HTML in temporäre Datei schreiben und per URL laden # (setHtml() hat Größenlimit und kann bei großen Inhalten einfrieren) self._temp_html_file = tempfile.NamedTemporaryFile(mode="w", suffix=".html", encoding="utf-8", delete=False) self._temp_html_file.write(html) self._temp_html_file.close() self._web_view.setUrl(QUrl.fromLocalFile(self._temp_html_file.name)) self._graph_loaded = True logger.info("Netzwerkgraph geladen") def _build_graph_data(self) -> tuple[str, str]: """ Konvertiert den Abhängigkeitsgraph in vis.js-kompatible JSON-Strukturen. Returns: tuple[str, str]: (nodes_json, edges_json) """ # Direkten Graph verwenden (nicht transitiv) direct_graph = self.dependency_graph.build_direct_graph(self.xsl_root_dir) # Pfad → ID Mapping all_paths = sorted(direct_graph.keys(), key=lambda p: self._rel_path(p).lower()) path_to_id: dict[Path, int] = {path: idx for idx, path in enumerate(all_paths)} # Nodes nodes = [] for path, node_id in path_to_id.items(): rel = self._rel_path(path) label = path.name direct_deps = len(direct_graph.get(path, set())) reverse_count = len(self._reverse_map.get(path, set())) title = f"{rel}
Importiert: {direct_deps}
Importiert von: {reverse_count}" # Knotengröße basierend auf Verbindungsanzahl value = direct_deps + reverse_count nodes.append( { "id": node_id, "label": label, "title": title, "value": max(value, 1), } ) # Edges (nur direkte Abhängigkeiten) edges = [] for path, deps in direct_graph.items(): from_id = path_to_id.get(path) if from_id is None: continue for dep in deps: to_id = path_to_id.get(dep) if to_id is not None: edges.append({"from": from_id, "to": to_id}) return json.dumps(nodes, ensure_ascii=False), json.dumps(edges, ensure_ascii=False) def _generate_html(self, nodes_json: str, edges_json: str) -> str: """ Generiert die HTML-Seite mit inline vis.js und Graph-Daten. Args: nodes_json: vis.js Nodes als JSON-String edges_json: vis.js Edges als JSON-String Returns: str: Vollständige HTML-Seite """ # vis.js Bibliothek einlesen vis_js_path = Path(__file__).parent.parent / "res" / "vis-network.min.js" try: vis_js_content = vis_js_path.read_text(encoding="utf-8") except Exception as e: logger.error(f"Konnte vis-network.min.js nicht laden: {e}") return f"

Fehler: vis-network.min.js nicht gefunden

{e}

" # Hintergrundfarbe aus Qt-Palette ableiten palette = self.palette() bg_color = palette.window().color() text_color = palette.windowText().color() bg_hex = bg_color.name() text_hex = text_color.name() return f"""
"""