Files
xsl-validator/src/ui/XslDependencyDialog.py
T

480 lines
17 KiB
Python
Raw Normal View History

"""
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"<b>{rel}</b><br>Importiert: {direct_deps}<br>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"<html><body><h2>Fehler: vis-network.min.js nicht gefunden</h2><p>{e}</p></body></html>"
# 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"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body {{
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: {bg_hex};
color: {text_hex};
font-family: sans-serif;
}}
#graph-container {{
width: 100%;
height: 100%;
}}
.vis-tooltip {{
background-color: {bg_hex} !important;
color: {text_hex} !important;
border: 1px solid #888 !important;
border-radius: 4px !important;
padding: 8px !important;
font-size: 13px !important;
box-shadow: 2px 2px 6px rgba(0,0,0,0.3) !important;
}}
</style>
<script type="text/javascript">
{vis_js_content}
</script>
</head>
<body>
<div id="graph-container"></div>
<script type="text/javascript">
var nodesData = {nodes_json};
var edgesData = {edges_json};
var nodes = new vis.DataSet(nodesData);
var edges = new vis.DataSet(edgesData);
var container = document.getElementById('graph-container');
var data = {{ nodes: nodes, edges: edges }};
var options = {{
nodes: {{
shape: 'dot',
scaling: {{
min: 8,
max: 28,
label: {{ enabled: true, min: 10, max: 16 }}
}},
font: {{
size: 12,
color: '{text_hex}',
strokeWidth: 2,
strokeColor: '{bg_hex}'
}},
color: {{
background: '#4a90d9',
border: '#2c5f9e',
highlight: {{ background: '#ff8c00', border: '#cc7000' }},
hover: {{ background: '#5da0e9', border: '#3a6fae' }}
}}
}},
edges: {{
arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
color: {{
color: '#888888',
highlight: '#ff8c00',
hover: '#aaaaaa',
opacity: 0.7
}},
smooth: {{ type: 'continuous' }}
}},
physics: {{
solver: 'barnesHut',
barnesHut: {{
gravitationalConstant: -3000,
centralGravity: 0.3,
springLength: 150,
springConstant: 0.04,
damping: 0.09
}},
stabilization: {{
enabled: true,
iterations: 200,
updateInterval: 25
}}
}},
interaction: {{
hover: true,
tooltipDelay: 200,
navigationButtons: true,
keyboard: true
}}
}};
var network = new vis.Network(container, data, options);
// Nachbar-Hervorhebung bei Klick
var allNodes = nodes.get();
var originalColors = {{}};
allNodes.forEach(function(node) {{
originalColors[node.id] = {{
color: node.color || options.nodes.color,
font: node.font || options.nodes.font
}};
}});
network.on("selectNode", function(params) {{
var selectedId = params.nodes[0];
var connectedNodes = network.getConnectedNodes(selectedId);
var connectedSet = new Set(connectedNodes);
connectedSet.add(selectedId);
var updates = [];
allNodes.forEach(function(node) {{
if (connectedSet.has(node.id)) {{
updates.push({{
id: node.id,
opacity: 1.0
}});
}} else {{
updates.push({{
id: node.id,
opacity: 0.15
}});
}}
}});
nodes.update(updates);
// Kanten dimmen
var allEdges = edges.get();
var connectedEdges = network.getConnectedEdges(selectedId);
var connectedEdgeSet = new Set(connectedEdges);
var edgeUpdates = [];
allEdges.forEach(function(edge) {{
if (connectedEdgeSet.has(edge.id)) {{
edgeUpdates.push({{ id: edge.id, color: {{ opacity: 1.0 }} }});
}} else {{
edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.08 }} }});
}}
}});
edges.update(edgeUpdates);
}});
network.on("deselectNode", function() {{
// Alle Knoten zurücksetzen
var updates = [];
allNodes.forEach(function(node) {{
updates.push({{
id: node.id,
opacity: 1.0
}});
}});
nodes.update(updates);
// Alle Kanten zurücksetzen
var allEdges = edges.get();
var edgeUpdates = [];
allEdges.forEach(function(edge) {{
edgeUpdates.push({{ id: edge.id, color: {{ opacity: 0.7 }} }});
}});
edges.update(edgeUpdates);
}});
</script>
</body>
</html>"""