Feat: Interaktiver XSL-Abhängigkeitsgraph mit vis.js und THIRD_PARTY_LICENSES aktualisiert

XslDependencyDialog mit zwei Tabs: Baumansicht (vorwärts/rückwärts-Abhängigkeiten)
und interaktiver Netzwerkgraph (vis.js in QWebEngineView mit Physics-Simulation,
Hover-Tooltips, Nachbar-Hervorhebung). Graceful Fallback wenn WebEngine fehlt.
THIRD_PARTY_LICENSES um psutil, PyInstaller, Pillow, vis-network ergänzt und
Versionen aktualisiert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 20:50:09 +01:00
parent 71fa48a514
commit 36911b111d
7 changed files with 965 additions and 14 deletions
+75 -14
View File
@@ -10,7 +10,7 @@ Python-Abhängigkeiten
================================================================================ ================================================================================
1. PySide6 1. PySide6
Version: >=6.9.1 Version: >=6.10.1
Lizenz: LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only Lizenz: LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
Webseite: https://www.qt.io/qt-for-python Webseite: https://www.qt.io/qt-for-python
GitHub: https://github.com/qt/pyside-pyside-setup GitHub: https://github.com/qt/pyside-pyside-setup
@@ -18,7 +18,7 @@ Python-Abhängigkeiten
Copyright: Copyright (C) The Qt Company Ltd. Copyright: Copyright (C) The Qt Company Ltd.
2. Pydantic 2. Pydantic
Version: >=2.9.1 Version: >=2.12.0
Lizenz: MIT License Lizenz: MIT License
Webseite: https://pydantic.dev Webseite: https://pydantic.dev
GitHub: https://github.com/pydantic/pydantic GitHub: https://github.com/pydantic/pydantic
@@ -26,21 +26,21 @@ Python-Abhängigkeiten
Copyright: Copyright (c) 2017 to present Pydantic Services Inc. Copyright: Copyright (c) 2017 to present Pydantic Services Inc.
3. Pydantic-Settings 3. Pydantic-Settings
Version: >=2.9.1 Version: >=2.12.0
Lizenz: MIT License Lizenz: MIT License
GitHub: https://github.com/pydantic/pydantic-settings GitHub: https://github.com/pydantic/pydantic-settings
Beschreibung: Settings management using Pydantic Beschreibung: Settings management using Pydantic
Copyright: Copyright (c) 2023 Pydantic Services Inc. Copyright: Copyright (c) 2023 Pydantic Services Inc.
4. Pydantic-YAML 4. Pydantic-YAML
Version: >=1.5.1 Version: >=1.6.0
Lizenz: MIT License Lizenz: MIT License
GitHub: https://github.com/NowanIlfideme/pydantic-yaml GitHub: https://github.com/NowanIlfideme/pydantic-yaml
Beschreibung: YAML support for Pydantic models Beschreibung: YAML support for Pydantic models
Copyright: Copyright (c) 2020 Anatoly Makarevich Copyright: Copyright (c) 2020 Anatoly Makarevich
5. Polars 5. Polars
Version: >=1.31.0 Version: >=1.37.0
Lizenz: MIT License Lizenz: MIT License
Webseite: https://pola.rs Webseite: https://pola.rs
GitHub: https://github.com/pola-rs/polars GitHub: https://github.com/pola-rs/polars
@@ -60,20 +60,53 @@ Python-Abhängigkeiten
Beschreibung: Python library for Apache Arrow Beschreibung: Python library for Apache Arrow
Copyright: Copyright (c) 2016-2025 The Apache Software Foundation Copyright: Copyright (c) 2016-2025 The Apache Software Foundation
8. pyqtdarktheme 8. psutil
Version: >=2.1.0 Version: >=6.1.1
Lizenz: MIT License Lizenz: BSD-3-Clause License
GitHub: https://github.com/5yutan5/PyQtDarkTheme GitHub: https://github.com/giampaolo/psutil
Beschreibung: A flat dark theme for PySide and PyQt Beschreibung: Cross-platform lib for process and system monitoring
Copyright: Copyright (c) 2021 Yunosuke Ohsugi Copyright: Copyright (c) 2009 Giampaolo Rodola
9. Ruff (Development) 9. Ruff (Development)
Version: >=0.14.8 Version: >=0.14.11
Lizenz: MIT License Lizenz: MIT License
GitHub: https://github.com/astral-sh/ruff GitHub: https://github.com/astral-sh/ruff
Beschreibung: An extremely fast Python linter and code formatter Beschreibung: An extremely fast Python linter and code formatter
Copyright: Copyright (c) 2022 Charlie Marsh Copyright: Copyright (c) 2022 Charlie Marsh
10. PyInstaller (Development)
Version: >=6.0.0
Lizenz: GPL-2.0 mit Bootloader-Ausnahme
Webseite: https://pyinstaller.org
GitHub: https://github.com/pyinstaller/pyinstaller
Beschreibung: Bundles Python applications into stand-alone executables
Copyright: Copyright (c) 2010-2025 PyInstaller Development Team
11. Pillow (Development)
Version: >=10.0.0
Lizenz: HPND License (Historical Permission Notice and Disclaimer)
Webseite: https://python-pillow.org
GitHub: https://github.com/python-pillow/Pillow
Beschreibung: Python Imaging Library (Fork)
Copyright: Copyright (c) 2010-2025 Jeffrey A. Clark and contributors
================================================================================
Eingebettete Bibliotheken
================================================================================
Diese Bibliotheken sind direkt im Quellcode von DocuMentor enthalten.
1. vis-network (vis.js)
Version: 9.1.9
Lizenz: Apache License 2.0 ODER MIT License (Dual-Lizenz)
Webseite: https://visjs.github.io/vis-network/
GitHub: https://github.com/visjs/vis-network
Beschreibung: A dynamic, browser-based network visualization library
Copyright: Copyright (c) 2011-2017 Almende B.V, http://almende.com
Copyright (c) 2017-2019 visjs contributors, https://github.com/visjs
Datei: src/res/vis-network.min.js
Hinweis: Wird inline in QWebEngineView für den XSL-Abhängigkeitsgraph verwendet
================================================================================ ================================================================================
Externe Tools (nicht eingebettet) Externe Tools (nicht eingebettet)
================================================================================ ================================================================================
@@ -144,6 +177,34 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--------------------------------------------------------------------------------
BSD-3-Clause License (psutil)
--------------------------------------------------------------------------------
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
LGPL-3.0 / GPL-2.0 / GPL-3.0 (PySide6) LGPL-3.0 / GPL-2.0 / GPL-3.0 (PySide6)
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
@@ -183,6 +244,6 @@ HINWEISE
da sich diese ändern können. da sich diese ändern können.
================================================================================ ================================================================================
Stand: Januar 2025 Stand: März 2026
Erstellt für: DocuMentor v0.1.0 Erstellt für: DocuMentor v1.0.0
================================================================================ ================================================================================
@@ -0,0 +1,69 @@
# Transformation ohne Force — Entscheidungslogik
Die zentrale Methode ist `is_up_to_date()` in transform.py:155-180. Sie basiert auf Modifikationszeiten (mtime), nicht auf Hashes.
## Ablauf
Die Prüfung erfolgt an zwei Stellen in der Pipeline:
1. Vor Saxon-Transformation (`transform_saxon()`) — transform.py:192-194
2. Vor PDF-Build (`build_pdf()`) — transform.py:322-324
Beide rufen `is_up_to_date()` auf, die prüft:
- Existiert die New-PDF?
- Ist die XML-Datei neuer als die New-PDF?
- Ist die XSL-Datei neuer als die New-PDF?
Wenn die New-PDF existiert und älter als alle Inputs ist → Transformation wird übersprungen mit `(True, "Übersprungen (aktuell)")`.
## Mermaid-Diagramm
```mermaid
flowchart TD
A["Transformation gestartet<br/>(force=False)"] --> B{"force == True?"}
B -- Ja --> EXEC["Transformation ausführen"]
B -- Nein --> C{"New-PDF existiert?"}
C -- Nein --> EXEC
C -- Ja --> D["mtime der New-PDF ermitteln"]
D --> E{"XML-Datei neuer<br/>als New-PDF?"}
E -- Ja --> EXEC
E -- Nein --> F{"XSL-Datei neuer<br/>als New-PDF?"}
F -- Ja --> EXEC
F -- Nein --> SKIP["Übersprungen (aktuell)<br/>return (True, 'Übersprungen')"]
EXEC --> S1["Schritt 1: Saxon<br/>XML → FO"]
S1 --> S1OK{"Saxon erfolgreich?"}
S1OK -- Nein --> FAIL["Pipeline abgebrochen"]
S1OK -- Ja --> S2CHECK{"force == True?<br/>(erneute Prüfung<br/>für build_pdf)"}
S2CHECK -- Ja --> S2["Schritt 2: FOP<br/>FO → PDF"]
S2CHECK -- Nein --> S2UP{"is_up_to_date()?"}
S2UP -- Ja --> S2SKIP["PDF-Build übersprungen"]
S2UP -- Nein --> S2
S2 --> S3["Schritt 3: diff-pdf<br/>PDF-Vergleich"]
S3 --> DONE["Pipeline abgeschlossen"]
style SKIP fill:#4CAF50,color:#fff
style EXEC fill:#2196F3,color:#fff
style FAIL fill:#f44336,color:#fff
style DONE fill:#4CAF50,color:#fff
style S2SKIP fill:#4CAF50,color:#fff
```
## Wichtige Details
- Keine Hash-basierte Prüfung: Die Skip-Logik nutzt ausschließlich `mtime`-Vergleiche, nicht die blake2b-Hashes (die werden nur für Änderungsverfolgung in der UI verwendet).
- Doppelte Prüfung: `is_up_to_date()` wird sowohl vor Saxon als auch vor FOP aufgerufen — theoretisch könnte Saxon ausgeführt, aber der PDF-Build übersprungen werden.
- Skip = Erfolg: Ein übersprungener Schritt gilt als erfolgreich `(True, ...)`, die Pipeline läuft weiter.
- Force-Aufruf: Über das Kontextmenü gibt es explizite Force-Methoden wie `_transform_all_xml_files_force()` in transformation.py.
## Erweiterung
```mermaid
flowchart TD
LOAD["Projekt geladen"] --> BUILD["XSL-Abhängigkeitsgraph aufbauen<br/>dict[Path, set[Path]]"]
BUILD --> CACHE["Im Speicher halten"]
TRANSFORM["is_up_to_date() aufgerufen"] --> CHECK{"Graph-Eintrag<br/>vorhanden?"}
CHECK -- Nein --> PARSE["XSL parsen, Imports auflösen,<br/>Eintrag erstellen"]
PARSE --> MTIME
CHECK -- Ja --> STALE{"mtime der XSL<br/>geändert seit letztem Parse?"}
STALE -- Ja --> PARSE
STALE -- Nein --> MTIME["mtime aller Abhängigkeiten<br/>gegen New-PDF prüfen"]
MTIME --> RESULT["Ergebnis"]
```
+34
View File
File diff suppressed because one or more lines are too long
+40
View File
@@ -381,6 +381,15 @@ class MainWindow(
else: else:
self.ui.menuProjekt.addAction(self.action_worker_metrics) self.ui.menuProjekt.addAction(self.action_worker_metrics)
# Menü-Aktion "Abhängigkeitsgraph" zum Aktion-Menü hinzufügen
from PySide6.QtGui import QIcon as _QIcon
self.action_xsl_dependencies = QAction("XSL-Abhängigkeitsgraph", self)
self.action_xsl_dependencies.setIcon(_QIcon(_QIcon.fromTheme("view-list-tree")))
self.action_xsl_dependencies.triggered.connect(self._show_xsl_dependency_dialog)
self.ui.menuAktion.addSeparator()
self.ui.menuAktion.addAction(self.action_xsl_dependencies)
# Menü-Aktion "Aus Datenbank laden" verbinden (macht das Gleiche wie Button) # Menü-Aktion "Aus Datenbank laden" verbinden (macht das Gleiche wie Button)
self.ui.actionAus_Datenbank_laden.triggered.connect(self.on_load_from_fn2_clicked) self.ui.actionAus_Datenbank_laden.triggered.connect(self.on_load_from_fn2_clicked)
@@ -402,6 +411,37 @@ class MainWindow(
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Öffnen des Einstellungen-Dialogs: {e}") logger.error(f"Fehler beim Öffnen des Einstellungen-Dialogs: {e}")
def _show_xsl_dependency_dialog(self):
"""Öffnet den XSL-Abhängigkeitsgraph-Dialog."""
try:
if not self.project:
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(self, "Fehler", "Kein Projekt geöffnet")
return
xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None)
if not xsl_dir or not xsl_dir.path_to_root_dir.exists():
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(self, "Fehler", "XSL-Verzeichnis nicht konfiguriert oder nicht vorhanden")
return
# Verwende den bestehenden Abhängigkeitsgraph (lazy init)
if not hasattr(self, "xsl_dependency_graph") or self.xsl_dependency_graph is None:
from xsl_dependencies import XslDependencyGraph
self.xsl_dependency_graph = XslDependencyGraph()
from ui.XslDependencyDialog import XslDependencyDialog
# open() statt exec() verwenden — QWebEngineView verträgt keinen verschachtelten Event-Loop
self._xsl_dep_dialog = XslDependencyDialog(self, xsl_dir.path_to_root_dir, self.xsl_dependency_graph)
self._xsl_dep_dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self._xsl_dep_dialog.open()
except Exception as e:
logger.error(f"Fehler beim Öffnen des Abhängigkeitsgraph-Dialogs: {e}")
def open_new_project_dialog(self): def open_new_project_dialog(self):
"""Öffnet Pdf-Projekt-Dialog.""" """Öffnet Pdf-Projekt-Dialog."""
try: try:
+479
View File
@@ -0,0 +1,479 @@
"""
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>"""
+165
View File
@@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'XslDependencyDialog.ui'
##
## Created by: Qt User Interface Compiler version 6.9.2
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import QCoreApplication, QMetaObject, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSplitter,
QTabWidget,
QTreeWidget,
QVBoxLayout,
QWidget,
)
class Ui_XslDependencyDialog(object):
def setupUi(self, XslDependencyDialog):
if not XslDependencyDialog.objectName():
XslDependencyDialog.setObjectName("XslDependencyDialog")
XslDependencyDialog.resize(1000, 700)
self.verticalLayout = QVBoxLayout(XslDependencyDialog)
self.verticalLayout.setObjectName("verticalLayout")
# TabWidget
self.tabWidget = QTabWidget(XslDependencyDialog)
self.tabWidget.setObjectName("tabWidget")
# === Tab 0: Baumansicht ===
self.treeTab = QWidget()
self.treeTab.setObjectName("treeTab")
self.treeTabLayout = QVBoxLayout(self.treeTab)
self.treeTabLayout.setObjectName("treeTabLayout")
# Suchfeld
self.searchLayout = QHBoxLayout()
self.searchLayout.setObjectName("searchLayout")
self.searchLabel = QLabel(self.treeTab)
self.searchLabel.setObjectName("searchLabel")
self.searchLayout.addWidget(self.searchLabel)
self.searchEdit = QLineEdit(self.treeTab)
self.searchEdit.setObjectName("searchEdit")
self.searchEdit.setClearButtonEnabled(True)
self.searchLayout.addWidget(self.searchEdit)
self.treeTabLayout.addLayout(self.searchLayout)
# Splitter mit zwei Bäumen
self.splitter = QSplitter(self.treeTab)
self.splitter.setObjectName("splitter")
self.splitter.setOrientation(Qt.Orientation.Horizontal)
# Linke Seite: XSL-Dateiliste
self.leftWidget = QWidget(self.splitter)
self.leftWidget.setObjectName("leftWidget")
self.leftLayout = QVBoxLayout(self.leftWidget)
self.leftLayout.setObjectName("leftLayout")
self.leftLayout.setContentsMargins(0, 0, 0, 0)
self.leftLabel = QLabel(self.leftWidget)
self.leftLabel.setObjectName("leftLabel")
self.leftLayout.addWidget(self.leftLabel)
self.fileTree = QTreeWidget(self.leftWidget)
self.fileTree.setObjectName("fileTree")
self.fileTree.setHeaderHidden(False)
self.fileTree.setRootIsDecorated(True)
self.fileTree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.fileTree.setAlternatingRowColors(True)
self.leftLayout.addWidget(self.fileTree)
self.splitter.addWidget(self.leftWidget)
# Rechte Seite: Abhängigkeitsdetails
self.rightWidget = QWidget(self.splitter)
self.rightWidget.setObjectName("rightWidget")
self.rightLayout = QVBoxLayout(self.rightWidget)
self.rightLayout.setObjectName("rightLayout")
self.rightLayout.setContentsMargins(0, 0, 0, 0)
self.rightLabel = QLabel(self.rightWidget)
self.rightLabel.setObjectName("rightLabel")
self.rightLayout.addWidget(self.rightLabel)
self.depTree = QTreeWidget(self.rightWidget)
self.depTree.setObjectName("depTree")
self.depTree.setHeaderHidden(False)
self.depTree.setRootIsDecorated(True)
self.depTree.setAlternatingRowColors(True)
self.rightLayout.addWidget(self.depTree)
self.splitter.addWidget(self.rightWidget)
self.treeTabLayout.addWidget(self.splitter)
self.tabWidget.addTab(self.treeTab, "")
# === Tab 1: Netzwerkgraph ===
self.graphTab = QWidget()
self.graphTab.setObjectName("graphTab")
self.graphTabLayout = QVBoxLayout(self.graphTab)
self.graphTabLayout.setObjectName("graphTabLayout")
self.graphTabLayout.setContentsMargins(0, 0, 0, 0)
self.graphContainer = QWidget(self.graphTab)
self.graphContainer.setObjectName("graphContainer")
self.graphContainerLayout = QVBoxLayout(self.graphContainer)
self.graphContainerLayout.setObjectName("graphContainerLayout")
self.graphContainerLayout.setContentsMargins(0, 0, 0, 0)
self.graphTabLayout.addWidget(self.graphContainer)
self.tabWidget.addTab(self.graphTab, "")
self.verticalLayout.addWidget(self.tabWidget)
# Statuszeile
self.statusLabel = QLabel(XslDependencyDialog)
self.statusLabel.setObjectName("statusLabel")
self.verticalLayout.addWidget(self.statusLabel)
# Button-Box
self.buttonBox = QDialogButtonBox(XslDependencyDialog)
self.buttonBox.setObjectName("buttonBox")
self.buttonBox.setOrientation(Qt.Orientation.Horizontal)
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Close)
self.buttonBox.setCenterButtons(True)
self.verticalLayout.addWidget(self.buttonBox)
self.retranslateUi(XslDependencyDialog)
self.buttonBox.rejected.connect(XslDependencyDialog.reject)
self.tabWidget.setCurrentIndex(0)
QMetaObject.connectSlotsByName(XslDependencyDialog)
# setupUi
def retranslateUi(self, XslDependencyDialog):
XslDependencyDialog.setWindowTitle(
QCoreApplication.translate("XslDependencyDialog", "XSL-Abhängigkeitsgraph", None)
)
self.searchLabel.setText(QCoreApplication.translate("XslDependencyDialog", "Suche:", None))
self.searchEdit.setPlaceholderText(
QCoreApplication.translate("XslDependencyDialog", "XSL-Datei filtern...", None)
)
self.leftLabel.setText(QCoreApplication.translate("XslDependencyDialog", "XSL-Dateien", None))
self.rightLabel.setText(QCoreApplication.translate("XslDependencyDialog", "Abhängigkeiten", None))
self.tabWidget.setTabText(0, QCoreApplication.translate("XslDependencyDialog", "Baumansicht", None))
self.tabWidget.setTabText(1, QCoreApplication.translate("XslDependencyDialog", "Netzwerkgraph", None))
self.statusLabel.setText("")
# retranslateUi
+103
View File
@@ -95,6 +95,109 @@ class XslDependencyGraph:
return result return result
def get_reverse_dependencies(self, xsl_file: Path, xsl_root_dir: Path) -> set[Path]:
"""
Gibt alle Dateien zurück, die die gegebene XSL-Datei direkt oder transitiv importieren/inkludieren.
Args:
xsl_file: Absoluter Pfad zur XSL-Datei
xsl_root_dir: Wurzelverzeichnis der XSL-Dateien (für den Scan)
Returns:
set[Path]: Menge aller Dateien, die diese Datei importieren
"""
xsl_file = xsl_file.resolve()
result: set[Path] = set()
# Scanne alle XSL-Dateien im Verzeichnis
if not xsl_root_dir.exists():
return result
for candidate in xsl_root_dir.rglob("*.xsl"):
candidate = candidate.resolve()
if candidate == xsl_file:
continue
deps = self.get_dependencies(candidate)
if xsl_file in deps:
result.add(candidate)
return result
def build_full_graph(self, xsl_root_dir: Path) -> dict[Path, set[Path]]:
"""
Baut den vollständigen Abhängigkeitsgraph für alle XSL-Dateien in einem Verzeichnis auf.
Args:
xsl_root_dir: Wurzelverzeichnis der XSL-Dateien
Returns:
dict[Path, set[Path]]: Mapping von XSL-Datei zu ihren Abhängigkeiten
"""
graph: dict[Path, set[Path]] = {}
if not xsl_root_dir.exists():
return graph
for xsl_file in sorted(xsl_root_dir.rglob("*.xsl")):
xsl_file = xsl_file.resolve()
deps = self.get_dependencies(xsl_file)
graph[xsl_file] = deps
logger.info(f"Vollständiger XSL-Graph aufgebaut: {len(graph)} Dateien")
return graph
def _get_direct_dependencies(self, xsl_file: Path) -> set[Path]:
"""
Gibt nur die direkten (nicht-transitiven) import/include-Abhängigkeiten zurück.
Args:
xsl_file: Absoluter Pfad zur XSL-Datei
Returns:
set[Path]: Menge der direkt importierten/inkludierten XSL-Dateien
"""
xsl_file = xsl_file.resolve()
result: set[Path] = set()
if not xsl_file.exists():
return result
try:
content = xsl_file.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(f"Konnte XSL-Datei nicht lesen: {xsl_file} ({e})")
return result
for match in _IMPORT_INCLUDE_PATTERN.finditer(content):
href = match.group(1)
referenced_path = (xsl_file.parent / href).resolve()
if referenced_path.exists():
result.add(referenced_path)
return result
def build_direct_graph(self, xsl_root_dir: Path) -> dict[Path, set[Path]]:
"""
Baut einen Graph mit nur direkten (nicht-transitiven) Abhängigkeiten auf.
Args:
xsl_root_dir: Wurzelverzeichnis der XSL-Dateien
Returns:
dict[Path, set[Path]]: Mapping von XSL-Datei zu ihren direkten Abhängigkeiten
"""
graph: dict[Path, set[Path]] = {}
if not xsl_root_dir.exists():
return graph
for xsl_file in sorted(xsl_root_dir.rglob("*.xsl")):
xsl_file = xsl_file.resolve()
graph[xsl_file] = self._get_direct_dependencies(xsl_file)
logger.info(f"Direkter XSL-Graph aufgebaut: {len(graph)} Dateien")
return graph
def invalidate(self, xsl_file: Path | None = None): def invalidate(self, xsl_file: Path | None = None):
""" """
Invalidiert den Cache für eine bestimmte Datei oder den gesamten Cache. Invalidiert den Cache für eine bestimmte Datei oder den gesamten Cache.