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:
+75
-14
@@ -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"]
|
||||||
|
```
|
||||||
Vendored
+34
File diff suppressed because one or more lines are too long
@@ -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:
|
||||||
|
|||||||
@@ -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>"""
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user