Compare commits
195 Commits
bbb5c4da3a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20722d4365 | |||
| cb3fa143fb | |||
| 08ec021b5f | |||
| b63dcf5efa | |||
| 40e308f670 | |||
| c2c16a3407 | |||
| a4bfe0dc1c | |||
| e9061d1b1f | |||
| 32e32a9b27 | |||
| 11eac074ef | |||
| eaf9b29003 | |||
| 4a40dee5cd | |||
| 368cb81b56 | |||
| ac5b74917f | |||
| ef985fb6af | |||
| fdfc36afb7 | |||
| 8b963adb4f | |||
| 1ea6465584 | |||
| 13141f9638 | |||
| 719cc50d16 | |||
| 4bc70dae75 | |||
| b7f9184344 | |||
| 14da7ee789 | |||
| 7aed0f3730 | |||
| 62cab1ccc4 | |||
| 9f6c6f266b | |||
| 2af6d8d9bb | |||
| fa4f327a3f | |||
| 0cec9347c1 | |||
| e509160621 | |||
| 8c5d129439 | |||
| 74e825d1f4 | |||
| ce87b0bbec | |||
| d66364e254 | |||
| 434c882d7d | |||
| 8bd25d06c0 | |||
| 3022a9a16f | |||
| aeb3323180 | |||
| c2a7921675 | |||
| 93a2473c36 | |||
| 791499169f | |||
| 407f1e0422 | |||
| ca26d181f3 | |||
| eae2472b7e | |||
| 735b3af09f | |||
| 3876e647b2 | |||
| 90d95b9ff8 | |||
| 661894f1ec | |||
| 0651fcb6eb | |||
| b62db18f0c | |||
| 3fb511601c | |||
| a8d8a4a3c1 | |||
| 3ef8fd0dc3 | |||
| 265b807263 | |||
| b4f2bf60c6 | |||
| 15ff034a2b | |||
| 9c27e4df01 | |||
| 0412874f08 | |||
| 6c2e2efe22 | |||
| 9f222208c0 | |||
| beade6ba2e | |||
| 1ffd565bd7 | |||
| e8732d749a | |||
| 5a97979585 | |||
| 0fd0349a78 | |||
| 5129d3c9f6 | |||
| cec3b4fb84 | |||
| 38b2d8c3b2 | |||
| 9c49fc61c0 | |||
| 406eac80e4 | |||
| 4fba3379b9 | |||
| 9307a18e04 | |||
| 6a5ae4e7b0 | |||
| 479d176536 | |||
| bd2be347f6 | |||
| 18f9c33bbb | |||
| 349ee69a7f | |||
| 3b3e997d13 | |||
| ddfbf9b8cb | |||
| 0b69adf8c9 | |||
| 31c65fb9fd | |||
| 4ee0923721 | |||
| 4b89743404 | |||
| 6e03093ce9 | |||
| 51d6168697 | |||
| 46fc1d4fa4 | |||
| 5d5dee0bbf | |||
| 4f5ce4c750 | |||
| dc17824e84 | |||
| 0fe5359299 | |||
| 563d9f52db | |||
| 732d00c4ad | |||
| 500a8c5340 | |||
| c6379c04f6 | |||
| 01e97848a7 | |||
| 8aaa3331d7 | |||
| d10a572b50 | |||
| a87c73401b | |||
| 8ca6d08133 | |||
| cd273505af | |||
| bdc5e8331f | |||
| 330bcfe124 | |||
| c137d9e02e | |||
| ea8b94acc7 | |||
| 4bbee57d41 | |||
| 43c54923f8 | |||
| a910633c64 | |||
| 899525a75c | |||
| 0a139193b4 | |||
| 0da80849d4 | |||
| 014b11abd2 | |||
| 6ff463b7ef | |||
| 8bb0aabb51 | |||
| 27b4292c2f | |||
| 0b5a441a5d | |||
| 3877359b42 | |||
| 769d15fd86 | |||
| 6317a27378 | |||
| 4fa4c152a5 | |||
| ec23b2455a | |||
| 7f2cccf317 | |||
| a5fae8cf55 | |||
| 191d398604 | |||
| f204ad1f1e | |||
| ac3513504d | |||
| 65d8379c15 | |||
| a3642608b4 | |||
| ff86a07f0e | |||
| d9670ddfbe | |||
| 03b23eb6a9 | |||
| 1d77173c49 | |||
| fb0e9809f6 | |||
| c3f8103572 | |||
| 3f5602de01 | |||
| 1db6e27be8 | |||
| 385bda5331 | |||
| 5d4230b4cb | |||
| 3feafcbce8 | |||
| 5165ea6f1d | |||
| 0e20f93c0d | |||
| 234b3461b7 | |||
| 6b078aa3e7 | |||
| 7e2fec4c7b | |||
| 591c7ff94c | |||
| 673023d921 | |||
| 71d79ab30c | |||
| 8f4e18b248 | |||
| 0387be0e96 | |||
| ca16855e81 | |||
| 7e31057bfa | |||
| d3fbfc4094 | |||
| f6b265b370 | |||
| 3a98304a5c | |||
| b87e8498e6 | |||
| 67275eb2f5 | |||
| 955adc0c45 | |||
| e7f5590934 | |||
| c46294159c | |||
| 1df541d0f9 | |||
| 09605ee495 | |||
| 55088354ab | |||
| 83f6d63c27 | |||
| b41b4112c4 | |||
| 9fd5bd5a52 | |||
| f4a1bce9ae | |||
| 5d0e2c90bd | |||
| c61108fe1b | |||
| d1cffe8ef9 | |||
| 2e6d0b1d6d | |||
| 34c8245ae9 | |||
| c7f53416ca | |||
| 20a14b3c62 | |||
| f4cfb5cbc0 | |||
| eacc205865 | |||
| 566a7b97dd | |||
| d1fa989016 | |||
| 4875a1ed42 | |||
| 0f07947879 | |||
| 8884d15e69 | |||
| 3e1f59fd12 | |||
| 13e4922272 | |||
| 1996f31f43 | |||
| e07b460cdd | |||
| 9ba32f1bb8 | |||
| 8286d0f0e5 | |||
| 088fd85572 | |||
| 8be1848ba9 | |||
| aaedc2d713 | |||
| ada7e203e3 | |||
| 6babdd226e | |||
| 202088d1d3 | |||
| 523774707b | |||
| 016da8318d | |||
| 1bb7a42735 | |||
| aa067a3165 |
@@ -3,6 +3,8 @@
|
|||||||
# =========================================================
|
# =========================================================
|
||||||
**/target/
|
**/target/
|
||||||
dependency-reduced-pom.xml
|
dependency-reduced-pom.xml
|
||||||
|
# Generierte Flat-POM-Dateien des flatten-maven-plugin (CI-friendly Versioning)
|
||||||
|
**/.flattened-pom.xml
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# Eclipse / IDE
|
# Eclipse / IDE
|
||||||
@@ -75,3 +77,4 @@ replay_pid*
|
|||||||
/run-milestone.ps1
|
/run-milestone.ps1
|
||||||
/run-v11.ps1
|
/run-v11.ps1
|
||||||
.m2repo
|
.m2repo
|
||||||
|
/start-headless.bat
|
||||||
|
|||||||
@@ -11,9 +11,18 @@ Ab V2.0 wird die Anwendung um eine **lokale JavaFX-Desktop-GUI** erweitert. Die
|
|||||||
@docs/specs/meilensteine-v2_0.md
|
@docs/specs/meilensteine-v2_0.md
|
||||||
|
|
||||||
Für die Umsetzung ist zusätzlich immer das aktuell aktive Arbeitspaket unter `docs/workpackages/` maßgeblich.
|
Für die Umsetzung ist zusätzlich immer das aktuell aktive Arbeitspaket unter `docs/workpackages/` maßgeblich.
|
||||||
Dateinamensschema: `M9 - Arbeitspakete.md`, `M10 - Arbeitspakete.md`, … `M13 - Arbeitspakete.md`
|
Dateinamensschema: `M9 - Arbeitspakete.md`, `M10 - Arbeitspakete.md`, … `M13 - Arbeitspakete.md`, `M14_-_Arbeitspakete.md`, `M15_-_Arbeitspakete.md`.
|
||||||
Nicht raten, wenn Dokumente fehlen, unklar sind oder sich widersprechen.
|
Nicht raten, wenn Dokumente fehlen, unklar sind oder sich widersprechen.
|
||||||
|
|
||||||
|
## Modulare Architektur-Übersichten
|
||||||
|
Detailwissen über Pakete, Schlüsselklassen, Ports und Bootstrap-Verdrahtung ist in drei modularen Übersichtsdokumenten unter `docs/architecture/` ausgelagert. Wer in einem bestimmten Modul arbeitet, liest diese Datei zusätzlich zu CLAUDE.md:
|
||||||
|
|
||||||
|
- `docs/architecture/domain-overview.md` – `pdf-umbenenner-domain` und `pdf-umbenenner-application`: Domänenmodell, Inbound- und Outbound-Ports, Application-Services.
|
||||||
|
- `docs/architecture/gui-overview.md` – `pdf-umbenenner-adapter-in-gui`: Workspace-/Tab-Struktur, View-Modelle, GUI-interne Ports, JavaFX-Threading-Modell.
|
||||||
|
- `docs/architecture/adapter-overview.md` – `pdf-umbenenner-adapter-out`, `pdf-umbenenner-adapter-in-cli`, `pdf-umbenenner-bootstrap`: konkrete Outbound-Adapter, CLI-Einstiegspunkt, Verdrahtungslogik und Provider-Auswahl.
|
||||||
|
|
||||||
|
Für Arbeit ausschließlich in einem dieser Bereiche genügt CLAUDE.md plus die jeweils passende Übersichtsdatei.
|
||||||
|
|
||||||
## Priorisierung der Regeln
|
## Priorisierung der Regeln
|
||||||
Die Dokumente haben folgende feste Bedeutung:
|
Die Dokumente haben folgende feste Bedeutung:
|
||||||
|
|
||||||
@@ -47,9 +56,10 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
|
|||||||
- `--config <pfad>` steht für GUI und headless zur Verfügung
|
- `--config <pfad>` steht für GUI und headless zur Verfügung
|
||||||
- kein Webserver
|
- kein Webserver
|
||||||
- kein Applikationsserver
|
- kein Applikationsserver
|
||||||
- keine Dauerlauf-Anwendung
|
- keine Dauerlauf-Anwendung (Ausnahme: GUI-Modus mit aktivem Scheduler, s. Scheduler-Ausnahme)
|
||||||
- kein interner Scheduler
|
- kein interner Scheduler (Ausnahme: optionaler GUI-Scheduler ab V3.2, s. Scheduler-Ausnahme)
|
||||||
- keine EXE, kein Installer
|
- das Shade-JAR bleibt das primäre Distributionsartefakt
|
||||||
|
- zusätzlicher nativer Windows-Installer (MSI) ab V3.0 via Maven-Profil `release` (jpackage, WiX Toolset 3.x im PATH erforderlich); der Normalbuild `mvn clean verify` bleibt vom Profil unberührt und benötigt kein WiX
|
||||||
- Log4j2 für Logging
|
- Log4j2 für Logging
|
||||||
- SQLite als lokaler Persistenzspeicher
|
- SQLite als lokaler Persistenzspeicher
|
||||||
- JavaFX wird mit dem JAR ausgeliefert (kein separates JavaFX-Setup)
|
- JavaFX wird mit dem JAR ausgeliefert (kein separates JavaFX-Setup)
|
||||||
@@ -67,9 +77,28 @@ Wenn Dokumente fehlen, unklar sind oder sich widersprechen, nicht raten und kein
|
|||||||
- `pdf-umbenenner-application`
|
- `pdf-umbenenner-application`
|
||||||
- `pdf-umbenenner-adapter-in-cli`
|
- `pdf-umbenenner-adapter-in-cli`
|
||||||
- `pdf-umbenenner-adapter-in-gui`
|
- `pdf-umbenenner-adapter-in-gui`
|
||||||
|
- `pdf-umbenenner-adapter-in-scheduler`
|
||||||
- `pdf-umbenenner-adapter-out`
|
- `pdf-umbenenner-adapter-out`
|
||||||
- `pdf-umbenenner-bootstrap`
|
- `pdf-umbenenner-bootstrap`
|
||||||
|
|
||||||
|
## Scheduler-Ausnahme (ab V3.2)
|
||||||
|
|
||||||
|
Ab V3.2 enthält der GUI-Modus einen optionalen internen Scheduler, der periodisch
|
||||||
|
automatische Verarbeitungsläufe anstößt. Die folgenden Regeln gelten abweichend von
|
||||||
|
den allgemeinen Leitplanken:
|
||||||
|
|
||||||
|
- Der Scheduler ist **ausschließlich im GUI-Modus** verfügbar. Im headless Betrieb werden
|
||||||
|
`scheduler.enabled` und `scheduler.interval.seconds` vollständig ignoriert.
|
||||||
|
- Das Modul `pdf-umbenenner-adapter-in-scheduler` erfüllt eine gemischte Rolle als
|
||||||
|
technischer Treiber und Adapter. Dies ist ein bewusster Architekturkompromiss, kein
|
||||||
|
Architekturbruch.
|
||||||
|
- `pdf-umbenenner-adapter-in-scheduler` enthält **kein JavaFX**.
|
||||||
|
- **Kein WatchService:** Der Scheduler löst reguläre Verarbeitungsläufe periodisch aus;
|
||||||
|
er nutzt keinen Dateisystem-Event-Mechanismus.
|
||||||
|
- Das bestehende Datenbankschema bleibt in V3.2 unverändert; keine
|
||||||
|
Scheduler-spezifische Schemaerweiterung.
|
||||||
|
- Token- und Kostentracking sind nicht Bestandteil von V3.2.
|
||||||
|
|
||||||
## Architekturregeln
|
## Architekturregeln
|
||||||
- Strikte **hexagonale Architektur / Ports and Adapters**
|
- Strikte **hexagonale Architektur / Ports and Adapters**
|
||||||
- Abhängigkeiten zeigen immer **nach innen**
|
- Abhängigkeiten zeigen immer **nach innen**
|
||||||
@@ -120,9 +149,9 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
|
|||||||
## Globale fachliche Leitplanken
|
## Globale fachliche Leitplanken
|
||||||
- Zielformat: `YYYY-MM-DD - Titel.pdf`
|
- Zielformat: `YYYY-MM-DD - Titel.pdf`
|
||||||
- Bei Namenskollisionen: `YYYY-MM-DD - Titel(1).pdf`, `YYYY-MM-DD - Titel(2).pdf`, ...
|
- Bei Namenskollisionen: `YYYY-MM-DD - Titel(1).pdf`, `YYYY-MM-DD - Titel(2).pdf`, ...
|
||||||
- Die **20 Zeichen** gelten nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit
|
- Die **konfigurierte maximale Titellänge** gilt nur für den **Basistitel**; das Dubletten-Suffix zählt nicht mit
|
||||||
- Das Dubletten-Suffix wird unmittelbar vor `.pdf` angehängt
|
- Das Dubletten-Suffix wird unmittelbar vor `.pdf` angehängt
|
||||||
- Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen
|
- Titel sind **deutsch**, verständlich, eindeutig und enthalten keine Sonderzeichen außer Leerzeichen, Bindestrichen, Punkten, Kommas und Ampersands
|
||||||
- Eigennamen bleiben unverändert
|
- Eigennamen bleiben unverändert
|
||||||
- Datumsermittlung mit Priorität aus den fachlichen Anforderungen; wenn kein belastbares Datum eindeutig ableitbar ist, ist das **aktuelle Datum** als Fallback erlaubt
|
- Datumsermittlung mit Priorität aus den fachlichen Anforderungen; wenn kein belastbares Datum eindeutig ableitbar ist, ist das **aktuelle Datum** als Fallback erlaubt
|
||||||
- Mehrdeutige Dokumente liefern **kein** unsicheres Ergebnis, sondern einen Fehler
|
- Mehrdeutige Dokumente liefern **kein** unsicheres Ergebnis, sondern einen Fehler
|
||||||
@@ -135,9 +164,15 @@ Ein Arbeitspaket ist erst fertig, wenn die betroffenen öffentlichen Klassen und
|
|||||||
## Aktiver Implementierungsstand
|
## Aktiver Implementierungsstand
|
||||||
V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben.
|
V1.1 ist vollständig umgesetzt, dokumentiert, getestet und freigegeben.
|
||||||
|
|
||||||
Der aktive Entwicklungsstand ist **V2.0**. Ziel ist der Ausbau um eine lokale JavaFX-Desktop-GUI als neuen Standardstart, ohne die bestehende Architektur, das Standalone-JAR-Betriebsmodell oder den headless Scheduler-Betrieb aufzugeben.
|
Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technische Tests) ist abgeschlossen.
|
||||||
|
|
||||||
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt in V2.0 unverändert.
|
**V2.9 ist abgeschlossen.** Der Tab „Verarbeitungslauf" wurde erweitert um eine integrierte PDF-Vorschau (Lazy-Rendering direkt über PDFBox, In-Memory-Cache, Seitennavigation) sowie einen editierbaren Dateiname-Bereich mit Live-Validierung, Dirty-State-Dialog und atomarer Dateisystem-/DB-Transaktion inklusive Rollback und Fingerprint-basierter Konfliktauflösung. Die zugehörigen neuen Ports, Use Cases und Adapter sind in den modularen Architektur-Übersichten beschrieben.
|
||||||
|
|
||||||
|
Verhaltensänderungen seit V2.9: Die GUI startet maximiert, und die zuletzt geladene Konfigurationsdatei wird beim Start automatisch wieder geladen; existiert sie nicht mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
||||||
|
|
||||||
|
**V3.2 ist abgeschlossen.** Der GUI-Modus wurde um einen optionalen automatischen Scheduler erweitert (neuer Tab „Scheduler"). Der Scheduler startet periodisch Verarbeitungsläufe; Intervall und Autostart sind konfigurierbar. Während der Scheduler aktiv ist, sind der Konfigurations-Tab und das manuelle Starten von Läufen gesperrt. Im headless Betrieb werden Scheduler-Parameter vollständig ignoriert.
|
||||||
|
|
||||||
|
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert.
|
||||||
|
|
||||||
## Statussemantik
|
## Statussemantik
|
||||||
|
|
||||||
@@ -226,6 +261,13 @@ Bestehende Kommentare mit solchen Bezeichnern, die durch eigene Änderungen ber
|
|||||||
- Keine stillen Änderungen am bestehenden headless Batch-Betrieb
|
- Keine stillen Änderungen am bestehenden headless Batch-Betrieb
|
||||||
- GUI-Code darf den headless Pfad nicht unnötig früh initialisieren
|
- GUI-Code darf den headless Pfad nicht unnötig früh initialisieren
|
||||||
|
|
||||||
|
## Commit und Push nach jeder Implementierung
|
||||||
|
Nach jeder Implementierung oder Dateiänderung wird ein Commit auf `main` erstellt und gepusht:
|
||||||
|
1. Geänderte Dateien stagen und committen
|
||||||
|
2. `git push origin main` ausführen
|
||||||
|
3. Schlägt der Push mit einem AUTH-Fehler fehl: 1 Sekunde warten, dann genau **einen** weiteren Versuch unternehmen
|
||||||
|
4. Schlägt auch der zweite Versuch fehl: Fehler benennen, keinen weiteren automatischen Retry
|
||||||
|
|
||||||
## Definition of Done pro Arbeitspaket
|
## Definition of Done pro Arbeitspaket
|
||||||
Ein Arbeitspaket ist erst fertig, wenn:
|
Ein Arbeitspaket ist erst fertig, wenn:
|
||||||
- der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist
|
- der Zielumfang des aktuellen Arbeitspakets vollständig umgesetzt ist
|
||||||
@@ -253,6 +295,8 @@ Ein Arbeitspaket ist erst fertig, wenn:
|
|||||||
- Nach Änderungen den kleinsten sinnvollen Build-/Test-Umfang ausführen
|
- Nach Änderungen den kleinsten sinnvollen Build-/Test-Umfang ausführen
|
||||||
- Build-Validierung vom Parent-Root (Beispiel für vollständigen Reactor-Build ab V2.0):
|
- Build-Validierung vom Parent-Root (Beispiel für vollständigen Reactor-Build ab V2.0):
|
||||||
`.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make`
|
`.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make`
|
||||||
|
- MSI-Build (nur lokal auf der Entwicklungsmaschine, WiX Toolset 3.x im PATH erforderlich):
|
||||||
|
`.\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests`
|
||||||
- Schlägt der Build fehl: Fehler beheben, erneut bauen, erst dann weiter
|
- Schlägt der Build fehl: Fehler beheben, erneut bauen, erst dann weiter
|
||||||
- Vor Abschluss sicherstellen, dass der relevante Maven-Reactor-Stand fehlerfrei ist
|
- Vor Abschluss sicherstellen, dass der relevante Maven-Reactor-Stand fehlerfrei ist
|
||||||
- Fehler nicht kaschieren; Ursachen sauber beheben oder offen benennen
|
- Fehler nicht kaschieren; Ursachen sauber beheben oder offen benennen
|
||||||
@@ -273,10 +317,13 @@ Verbindlich zweckmäßige Parameter:
|
|||||||
- `max.retries.transient` – max. historisierte transiente Fehlversuche pro Fingerprint (**Integer >= 1**, `0` ist ungültig)
|
- `max.retries.transient` – max. historisierte transiente Fehlversuche pro Fingerprint (**Integer >= 1**, `0` ist ungültig)
|
||||||
- `max.pages` – Seitenlimit
|
- `max.pages` – Seitenlimit
|
||||||
- `max.text.characters` – maximale Zeichenzahl für KI-Eingabe
|
- `max.text.characters` – maximale Zeichenzahl für KI-Eingabe
|
||||||
|
- `max.title.length` – maximale Länge des Basistitels in Zeichen (gültiger Bereich 10..120, Default 60)
|
||||||
- `prompt.template.file` – externe Prompt-Datei
|
- `prompt.template.file` – externe Prompt-Datei
|
||||||
- `log.ai.sensitive` – sensible KI-Logausgabe freischalten (Boolean, Default: `false`)
|
- `log.ai.sensitive` – sensible KI-Logausgabe freischalten (Boolean, Default: `false`)
|
||||||
- `runtime.lock.file` – Lock-Datei (optional)
|
- `runtime.lock.file` – Lock-Datei (optional)
|
||||||
- `log.directory` – Log-Verzeichnis (optional)
|
- `log.directory` – Log-Verzeichnis (optional)
|
||||||
|
- `scheduler.enabled` – Scheduler im GUI-Modus aktivieren (Boolean, Default: `false`; wird im headless Betrieb vollständig ignoriert)
|
||||||
|
- `scheduler.interval.seconds` – Intervall zwischen automatischen Läufen in Sekunden (Integer >= 30, Pflicht wenn `scheduler.enabled=true`; wird im headless Betrieb vollständig ignoriert)
|
||||||
|
|
||||||
Pro Provider-Familie existiert ein eigener Parameter-Namensraum:
|
Pro Provider-Familie existiert ein eigener Parameter-Namensraum:
|
||||||
|
|
||||||
@@ -306,17 +353,16 @@ Verbindlicher Ablauf:
|
|||||||
6. Erst danach den normalen Lauf fortsetzen
|
6. Erst danach den normalen Lauf fortsetzen
|
||||||
|
|
||||||
## Nicht-Ziele / Verbote
|
## Nicht-Ziele / Verbote
|
||||||
- kein manueller Verarbeitungslauf aus der GUI (erst V2.1+)
|
- kein manueller Verarbeitungslauf aus der GUI (kein vollständiger Lauf; Bearbeitungen nach Lauf sind zulässig)
|
||||||
- kein DB-/Historien-Tab in der GUI (erst V2.x+)
|
- kein DB-/Historien-Tab in der GUI (erst V2.x+)
|
||||||
- kein Kosten-Tracking (erst V2.x+)
|
- kein Kosten-Tracking (erst V2.x+)
|
||||||
- kein echter Mini-KI-Testaufruf mit fachlicher Antwortauswertung
|
- kein echter Mini-KI-Testaufruf mit fachlicher Antwortauswertung
|
||||||
- keine EXE, kein Installer
|
|
||||||
- kein Web-UI
|
- kein Web-UI
|
||||||
- keine REST-API zur Bedienung
|
- keine REST-API zur Bedienung
|
||||||
- keine OCR innerhalb der Java-Anwendung
|
- keine OCR innerhalb der Java-Anwendung
|
||||||
- keine DMS-Funktionalität
|
- keine DMS-Funktionalität
|
||||||
- kein menschlicher Review-Workflow in der Anwendung
|
- kein menschlicher Review-Workflow in der Anwendung
|
||||||
- keine interne Scheduler-Logik
|
- keine interne Scheduler-Logik außerhalb des optionalen GUI-Schedulers (s. Scheduler-Ausnahme)
|
||||||
- keine Architekturbrüche
|
- keine Architekturbrüche
|
||||||
- keine neuen Bibliotheken oder Frameworks ohne klare Notwendigkeit und Begründung
|
- keine neuen Bibliotheken oder Frameworks ohne klare Notwendigkeit und Begründung
|
||||||
- **keine** automatische Fallback-Umschaltung zwischen KI-Providern
|
- **keine** automatische Fallback-Umschaltung zwischen KI-Providern
|
||||||
|
|||||||
Vendored
+194
@@ -0,0 +1,194 @@
|
|||||||
|
// Jenkins-Pipeline für den PDF KI Renamer
|
||||||
|
// Läuft auf einem Linux-Container (Synology NAS).
|
||||||
|
// Der MSI-Build ist Windows-only (jpackage + WiX Toolset 3.x). Jenkins läuft im
|
||||||
|
// Linux-Container auf Synology NAS und kann kein MSI erzeugen. Der MSI-Build
|
||||||
|
// wird bewusst manuell auf der Windows-Entwicklungsmaschine ausgeführt:
|
||||||
|
// .\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests
|
||||||
|
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
}
|
||||||
|
|
||||||
|
tools {
|
||||||
|
maven 'maven-3'
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAJOR und MINOR werden manuell als Jenkins-Parameter gepflegt.
|
||||||
|
// BUILD_NUMBER wird automatisch durch Jenkins vergeben.
|
||||||
|
// Die resultierende Versionsnummer lautet: MAJOR.MINOR.BUILD_NUMBER
|
||||||
|
parameters {
|
||||||
|
string(name: 'MAJOR', defaultValue: '3', description: 'SemVer MAJOR (manuell)')
|
||||||
|
string(name: 'MINOR', defaultValue: '0', description: 'SemVer MINOR (manuell)')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
|
||||||
|
stage('Version bestimmen') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def isManual = !currentBuild.getBuildCauses('hudson.model.Cause$UserIdCause').isEmpty()
|
||||||
|
def jenkinsHome = env.JENKINS_HOME ?: '/var/jenkins_home'
|
||||||
|
def safeJobName = env.JOB_NAME.replaceAll(/[^A-Za-z0-9._-]/, '_')
|
||||||
|
def stateDir = "${jenkinsHome}/version-state"
|
||||||
|
def stateFile = "${stateDir}/${safeJobName}.properties"
|
||||||
|
|
||||||
|
if (isManual) {
|
||||||
|
env.EFFECTIVE_MAJOR = params.MAJOR
|
||||||
|
env.EFFECTIVE_MINOR = params.MINOR
|
||||||
|
|
||||||
|
sh """
|
||||||
|
mkdir -p '${stateDir}'
|
||||||
|
cat > '${stateFile}' <<'EOF'
|
||||||
|
MAJOR=${params.MAJOR}
|
||||||
|
MINOR=${params.MINOR}
|
||||||
|
EOF
|
||||||
|
"""
|
||||||
|
|
||||||
|
echo "Manueller Build erkannt. Version gespeichert: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||||
|
} else {
|
||||||
|
def stateExists = (sh(script: "[ -f '${stateFile}' ]", returnStatus: true) == 0)
|
||||||
|
|
||||||
|
if (stateExists) {
|
||||||
|
env.EFFECTIVE_MAJOR = sh(
|
||||||
|
script: "grep '^MAJOR=' '${stateFile}' | cut -d= -f2-",
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
env.EFFECTIVE_MINOR = sh(
|
||||||
|
script: "grep '^MINOR=' '${stateFile}' | cut -d= -f2-",
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
echo "Automatischer Build erkannt. Gespeicherte Version verwendet: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||||
|
} else {
|
||||||
|
env.EFFECTIVE_MAJOR = params.MAJOR
|
||||||
|
env.EFFECTIVE_MINOR = params.MINOR
|
||||||
|
|
||||||
|
echo "Automatischer Build ohne gespeicherten Stand. Fallback auf Parameter: ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // stage: Version bestimmen
|
||||||
|
|
||||||
|
stage('Maven Build') {
|
||||||
|
steps {
|
||||||
|
catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') {
|
||||||
|
// -Drevision übergibt die vollständige Versionsnummer an Maven.
|
||||||
|
// Das flatten-maven-plugin im Parent-POM löst ${revision} in
|
||||||
|
// allen installierten POMs auf.
|
||||||
|
sh "mvn clean verify -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // stage: Maven Build
|
||||||
|
|
||||||
|
stage('SonarQube Analyse') {
|
||||||
|
steps {
|
||||||
|
catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') {
|
||||||
|
withSonarQubeEnv('SonarQube') {
|
||||||
|
sh "mvn sonar:sonar -Drevision=${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} -Dsonar.projectKey=pdf-umbenenner -Dsonar.projectName='PDF KI Renamer'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // stage: SonarQube Analyse
|
||||||
|
|
||||||
|
stage('Publish PIT Coverage') {
|
||||||
|
steps {
|
||||||
|
recordCoverage(
|
||||||
|
tools: [[
|
||||||
|
parser: 'PIT',
|
||||||
|
pattern: '**/target/pit-reports/mutations.xml'
|
||||||
|
]],
|
||||||
|
id: 'pit',
|
||||||
|
name: 'PIT Mutation Coverage',
|
||||||
|
failOnError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} // stage: Publish PIT Coverage
|
||||||
|
|
||||||
|
stage('Archive JAR') {
|
||||||
|
steps {
|
||||||
|
// Bash wird explizit erzwungen, weil Jenkins-Agenten standardmäßig
|
||||||
|
// sh (dash) verwenden, das kein mapfile kennt. mapfile zählt exakt
|
||||||
|
// die gefundenen Shade-JARs und bricht ab, wenn nicht genau eines vorhanden ist.
|
||||||
|
sh '''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mapfile -t JARS < <(find pdf-umbenenner-bootstrap/target \
|
||||||
|
-maxdepth 1 -name "pdf-umbenenner-bootstrap-*.jar" \
|
||||||
|
! -name "*-sources.jar" ! -name "*-javadoc.jar")
|
||||||
|
|
||||||
|
test "${#JARS[@]}" -eq 1 \
|
||||||
|
|| { echo "FEHLER: Erwartet genau 1 Shade-JAR, gefunden: ${#JARS[@]}"; exit 1; }
|
||||||
|
|
||||||
|
JAR_NAME="pdf-ki-renamer-${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}.jar"
|
||||||
|
cp "${JARS[0]}" "$JAR_NAME"
|
||||||
|
echo "Shade-JAR archiviert als: $JAR_NAME"
|
||||||
|
'''
|
||||||
|
archiveArtifacts artifacts: 'pdf-ki-renamer-*.jar', fingerprint: true
|
||||||
|
}
|
||||||
|
} // stage: Archive JAR
|
||||||
|
|
||||||
|
stage('Artefakt ablegen') {
|
||||||
|
steps {
|
||||||
|
sh '''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUILD_DIR="/builds/${EFFECTIVE_MAJOR}.${EFFECTIVE_MINOR}.${BUILD_NUMBER}"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
cp pdf-ki-renamer-*.jar "$BUILD_DIR/"
|
||||||
|
echo "Artefakt abgelegt unter: $BUILD_DIR"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
} // stage: Artefakt ablegen
|
||||||
|
|
||||||
|
stage('Berichte veröffentlichen') {
|
||||||
|
steps {
|
||||||
|
junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true
|
||||||
|
|
||||||
|
recordCoverage(
|
||||||
|
tools: [[parser: 'JACOCO', pattern: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate/jacoco.xml']],
|
||||||
|
enabledForFailure: true
|
||||||
|
)
|
||||||
|
|
||||||
|
publishHTML(target: [
|
||||||
|
reportName: 'JaCoCo HTML Report',
|
||||||
|
reportDir: 'pdf-umbenenner-coverage/target/site/jacoco-aggregate',
|
||||||
|
reportFiles: 'index.html',
|
||||||
|
keepAll: true,
|
||||||
|
alwaysLinkToLastBuild: true,
|
||||||
|
allowMissing: true
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} // stage: Berichte veröffentlichen
|
||||||
|
|
||||||
|
stage('Aufräumen') {
|
||||||
|
steps {
|
||||||
|
sh '''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
rm -f pdf-ki-renamer-*.jar
|
||||||
|
echo "Aufräumen abgeschlossen."
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
} // stage: Aufräumen
|
||||||
|
|
||||||
|
} // stages
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} erfolgreich abgeschlossen."
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "Build ${env.EFFECTIVE_MAJOR}.${env.EFFECTIVE_MINOR}.${env.BUILD_NUMBER} fehlgeschlagen."
|
||||||
|
}
|
||||||
|
always {
|
||||||
|
deleteDir()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // pipeline
|
||||||
@@ -4,14 +4,23 @@ Ein lokal gestartetes Java-Programm zur KI-gestützten Umbenennung bereits OCR-v
|
|||||||
|
|
||||||
Die Anwendung liest PDF-Dateien aus einem konfigurierbaren Quellordner, extrahiert den Text, ermittelt daraus per KI einen normierten Dateinamen und legt **eine Kopie** im Zielordner ab. Die Quelldateien bleiben unverändert.
|
Die Anwendung liest PDF-Dateien aus einem konfigurierbaren Quellordner, extrahiert den Text, ermittelt daraus per KI einen normierten Dateinamen und legt **eine Kopie** im Zielordner ab. Die Quelldateien bleiben unverändert.
|
||||||
|
|
||||||
|
> **V2.9:** Die Anwendung enthält eine lokale JavaFX-Desktop-GUI als Standardstart.
|
||||||
|
> Die GUI dient der Konfiguration, Validierung, technischen Diagnose und der Ausführung von Verarbeitungsläufen.
|
||||||
|
> Der Tab „Verarbeitungslauf" enthält eine integrierte PDF-Vorschau und einen editierbaren Dateiname-Bereich.
|
||||||
|
> Die GUI startet maximiert und lädt beim Start automatisch die zuletzt verwendete Konfigurationsdatei.
|
||||||
|
> Der headless Batch-Betrieb bleibt über `--headless` vollständig erhalten.
|
||||||
|
> Details zum Betrieb: [`docs/betrieb.md`](docs/betrieb.md)
|
||||||
|
|
||||||
## Zielbild
|
## Zielbild
|
||||||
|
|
||||||
Der PDF-Umbenenner ist bewusst als schlanke Batch-Anwendung ausgelegt:
|
Der PDF-Umbenenner ist als schlanke, lokal gestartete Anwendung ausgelegt:
|
||||||
|
|
||||||
- **Java 21**
|
- **Java 21**
|
||||||
- **Maven Multi-Module**
|
- **Maven Multi-Module**
|
||||||
- **ausführbares Standalone-JAR**
|
- **ausführbares Standalone-JAR** (ein gemeinsames JAR für GUI und headless)
|
||||||
- **lokaler Start**, z. B. über den **Windows Task Scheduler**
|
- **GUI-Standardstart** ab V2.0 (JavaFX-Desktop, offiziell Windows)
|
||||||
|
- **headless Betrieb** über `--headless`, z. B. für den **Windows Task Scheduler**
|
||||||
|
- **`--config <pfad>`** für GUI und headless
|
||||||
- **kein Webserver**
|
- **kein Webserver**
|
||||||
- **kein Applikationsserver**
|
- **kein Applikationsserver**
|
||||||
- **keine Dauerlauf-Anwendung**
|
- **keine Dauerlauf-Anwendung**
|
||||||
@@ -52,8 +61,8 @@ YYYY-MM-DD - Titel(2).pdf
|
|||||||
|
|
||||||
Wichtige Regeln:
|
Wichtige Regeln:
|
||||||
|
|
||||||
- die **20 Zeichen** beziehen sich nur auf den **Basistitel**
|
- die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel**
|
||||||
- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen
|
- das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge
|
||||||
- Titel werden auf **Deutsch** erzeugt
|
- Titel werden auf **Deutsch** erzeugt
|
||||||
- Eigennamen bleiben unverändert
|
- Eigennamen bleiben unverändert
|
||||||
- Quelldateien werden **nie** überschrieben, verschoben oder verändert
|
- Quelldateien werden **nie** überschrieben, verschoben oder verändert
|
||||||
@@ -86,6 +95,7 @@ Das Projekt ist strikt nach **Ports and Adapters / Hexagonal Architecture** aufg
|
|||||||
- `pdf-umbenenner-domain`
|
- `pdf-umbenenner-domain`
|
||||||
- `pdf-umbenenner-application`
|
- `pdf-umbenenner-application`
|
||||||
- `pdf-umbenenner-adapter-in-cli`
|
- `pdf-umbenenner-adapter-in-cli`
|
||||||
|
- `pdf-umbenenner-adapter-in-gui`
|
||||||
- `pdf-umbenenner-adapter-out`
|
- `pdf-umbenenner-adapter-out`
|
||||||
- `pdf-umbenenner-bootstrap`
|
- `pdf-umbenenner-bootstrap`
|
||||||
|
|
||||||
@@ -110,6 +120,7 @@ Typische Bereiche sind:
|
|||||||
- Timeout
|
- Timeout
|
||||||
- Seitenlimit
|
- Seitenlimit
|
||||||
- Textlimit für KI-Aufrufe
|
- Textlimit für KI-Aufrufe
|
||||||
|
- maximale Titellänge (`max.title.length`, Default 60, Bereich 10..120)
|
||||||
- Prompt-Datei
|
- Prompt-Datei
|
||||||
- Logging
|
- Logging
|
||||||
|
|
||||||
@@ -135,13 +146,30 @@ Unter Windows:
|
|||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
Das ausführbare Artefakt wird im Bootstrap-Modul erzeugt. Der Start erfolgt als normales Java-Programm:
|
Das ausführbare Artefakt wird im Bootstrap-Modul erzeugt.
|
||||||
|
|
||||||
|
**GUI-Standardstart** (öffnet die JavaFX-Desktop-Oberfläche):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -jar <bootstrap-jar>.jar
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
Die konkrete JAR-Datei hängt vom aktuellen Build-Stand ab.
|
**headless Betrieb** (Batch-/Scheduler-Start ohne GUI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless
|
||||||
|
```
|
||||||
|
|
||||||
|
**Explizite Konfigurationsdatei** (für GUI und headless):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --config C:\Pfad\zur\config.properties
|
||||||
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless --config C:\Pfad\zur\config.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
Das JAR ist das einzige Distributionsartefakt und enthält JavaFX für den GUI-Start bereits
|
||||||
|
integriert. Ausführliche Build-, Packaging- und Starthinweise sowie Informationen zur JavaFX-
|
||||||
|
Integration und zum headless Betrieb befinden sich in [`docs/betrieb.md`](docs/betrieb.md).
|
||||||
|
|
||||||
## Logging, Status und Nachvollziehbarkeit
|
## Logging, Status und Nachvollziehbarkeit
|
||||||
|
|
||||||
@@ -160,7 +188,10 @@ Die maßgeblichen Dokumente sind:
|
|||||||
- `CLAUDE.md`
|
- `CLAUDE.md`
|
||||||
- `docs/specs/technik-und-architektur.md`
|
- `docs/specs/technik-und-architektur.md`
|
||||||
- `docs/specs/fachliche-anforderungen.md`
|
- `docs/specs/fachliche-anforderungen.md`
|
||||||
- `docs/specs/meilensteine.md`
|
- `docs/specs/meilensteine-v2_0.md`
|
||||||
|
- `docs/betrieb.md`
|
||||||
|
- `docs/gui-bedienanleitung.md`
|
||||||
|
- `docs/freigabe-v2_0.md`
|
||||||
- `docs/workpackages/...`
|
- `docs/workpackages/...`
|
||||||
|
|
||||||
Empfohlene Leserichtung:
|
Empfohlene Leserichtung:
|
||||||
@@ -169,7 +200,9 @@ Empfohlene Leserichtung:
|
|||||||
2. technische Zielarchitektur
|
2. technische Zielarchitektur
|
||||||
3. fachliche Anforderungen
|
3. fachliche Anforderungen
|
||||||
4. Meilensteine
|
4. Meilensteine
|
||||||
5. aktives Arbeitspaket
|
5. `docs/betrieb.md` für Betriebs- und Startdetails
|
||||||
|
6. `docs/gui-bedienanleitung.md` für die GUI-Bedienung
|
||||||
|
7. aktives Arbeitspaket
|
||||||
|
|
||||||
## Entwicklungsleitplanken
|
## Entwicklungsleitplanken
|
||||||
|
|
||||||
@@ -186,9 +219,13 @@ Das Repository verfolgt einen inkrementellen, meilensteinbasierten Ausbau. Der a
|
|||||||
- Konfiguration und Startvalidierung
|
- Konfiguration und Startvalidierung
|
||||||
- Quellordner-Scan und PDF-Textauslese
|
- Quellordner-Scan und PDF-Textauslese
|
||||||
- Fingerprint, SQLite-Persistenz und Idempotenz
|
- Fingerprint, SQLite-Persistenz und Idempotenz
|
||||||
- KI-Integration für Benennungsvorschläge
|
- KI-Integration für Benennungsvorschläge (OpenAI-kompatibel und Anthropic Claude)
|
||||||
- Dateinamensbildung und Zielkopie
|
- Dateinamensbildung und Zielkopie
|
||||||
- Retry-Logik, Logging und betriebliche Robustheit
|
- Retry-Logik, Logging und betriebliche Robustheit
|
||||||
|
- JavaFX-Desktop-GUI als Standardstart (Konfigurationseditor, Validierung, technische Tests)
|
||||||
|
- Tab „Verarbeitungslauf" mit integrierter PDF-Vorschau pro Zeile und editierbarem Dateiname-Bereich
|
||||||
|
- Atomare Dateisystem- und Datenbankoperationen für manuelle Umbenennungen mit Konfliktauflösung
|
||||||
|
- headless Batch-Betrieb über `--headless` (rückwärtskompatibel zu V1.x)
|
||||||
|
|
||||||
## Lizenz / Nutzung
|
## Lizenz / Nutzung
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ max.retries.transient=3
|
|||||||
max.pages=10
|
max.pages=10
|
||||||
|
|
||||||
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
|
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
|
||||||
max.text.characters=5000
|
max.text.characters=1000
|
||||||
|
|
||||||
|
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
|
||||||
|
max.title.length=60
|
||||||
|
|
||||||
# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator
|
# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator
|
||||||
# in der Versuchshistorie.
|
# in der Versuchshistorie.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ sqlite.file=./work/test/pdf-umbenenner-test.db
|
|||||||
max.retries.transient=1
|
max.retries.transient=1
|
||||||
max.pages=5
|
max.pages=5
|
||||||
max.text.characters=2000
|
max.text.characters=2000
|
||||||
|
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
|
||||||
|
max.title.length=60
|
||||||
prompt.template.file=./config/prompts/template.txt
|
prompt.template.file=./config/prompts/template.txt
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,9 +2,35 @@ Du bist ein Assistent zur automatischen Benennung gescannter PDF-Dokumente.
|
|||||||
|
|
||||||
Analysiere den folgenden Dokumenttext und ermittle:
|
Analysiere den folgenden Dokumenttext und ermittle:
|
||||||
|
|
||||||
1. Einen inhaltlich passenden deutschen Titel (maximal 20 Zeichen, nur Buchstaben und Leerzeichen, keine Abkürzungen, keine generischen Bezeichnungen wie "Dokument", "Datei", "Scan" oder "PDF")
|
1. Einen inhaltlich passenden deutschen Titel nach dem Schema: {Absender} {Betreff_gekürzt}
|
||||||
2. Das relevanteste Datum des Dokuments
|
2. Das relevanteste Datum des Dokuments
|
||||||
|
|
||||||
|
Titelschema – verbindlich:
|
||||||
|
- Erster Teil: Absender (Person, Firma, Behörde, Institution) – so wie im Dokument genannt, Abkürzungen wie GmbH, AG, KfW, Kfz sind erlaubt
|
||||||
|
- Zweiter Teil: Betreff oder Kernaussage des Dokuments, so kurz wie möglich – bevorzugt aus einer vorhandenen Betreffzeile, sonst aus dem Dokumentinhalt abgeleitet
|
||||||
|
- Beide Teile durch ein Leerzeichen getrennt, kein Sonderzeichen außer Bindestrich und Leerzeichen
|
||||||
|
- **Maximal {MAX_TITLE_LENGTH} Zeichen gesamt – diese Grenze ist nicht verhandelbar und MUSS eingehalten werden**
|
||||||
|
- Keine generischen Begriffe wie "Dokument", "Datei", "Scan", "PDF", "Schreiben", "Brief"
|
||||||
|
- Titel auf Deutsch formulieren
|
||||||
|
|
||||||
|
WICHTIG – Längenbegrenzung ist deine Verantwortung:
|
||||||
|
Wenn ein idealer Titel länger als {MAX_TITLE_LENGTH} Zeichen wäre, darfst und musst du ihn selbst kürzen. Optionen:
|
||||||
|
- Betreff verkürzen (z.B. "Steuerbescheid 2024" statt "Einkommensteuerbescheid 2024")
|
||||||
|
- Unwesentliche Details weglassen
|
||||||
|
- Absender mit Standard-Abkürzung darstellen
|
||||||
|
- Absender weglassen und nur Betreff nutzen, falls sinnvoll
|
||||||
|
Liefere IMMER einen Titel, der das Zeichenlimit einhält. Niemals einen, der es überschreitet.
|
||||||
|
|
||||||
|
Beispiele für gute Titel:
|
||||||
|
- Stadtwerke Bochum Grundbesitzabgaben 2025
|
||||||
|
- Allianz Versicherung Kfz-Nachtrag Polo
|
||||||
|
- Finanzamt Bochum Steuerbescheid 2024
|
||||||
|
- KfW Förderbescheid Energieeffizienz
|
||||||
|
|
||||||
|
Beispiele für Kürzung bei Längenlimit:
|
||||||
|
- zu lang: "Versicherungsgesellschaft Allianz Versicherung AG Kfz-Versicherungsnachtrag Volkswagen Polo" → gekürzt: "Allianz Kfz-Nachtrag Polo"
|
||||||
|
- zu lang: "Bundesfinanzbehörde Finanzamt Bochum Bescheid zur Einkommensteuer Veranlagung" → gekürzt: "Finanzamt Bochum Steuerbescheid"
|
||||||
|
|
||||||
Datumsermittlung nach Priorität:
|
Datumsermittlung nach Priorität:
|
||||||
- Rechnungsdatum
|
- Rechnungsdatum
|
||||||
- Dokumentdatum
|
- Dokumentdatum
|
||||||
@@ -12,11 +38,15 @@ Datumsermittlung nach Priorität:
|
|||||||
- Schreibdatum oder Ende eines Leistungszeitraums
|
- Schreibdatum oder Ende eines Leistungszeitraums
|
||||||
- Kein Datum angeben, wenn kein belastbares Datum eindeutig ableitbar ist
|
- Kein Datum angeben, wenn kein belastbares Datum eindeutig ableitbar ist
|
||||||
|
|
||||||
Titelregeln:
|
|
||||||
- Titel auf Deutsch formulieren
|
|
||||||
- Eigennamen (Personen, Firmen, Orte) unverändert übernehmen
|
|
||||||
- Maximal 20 Zeichen (nur der Basistitel, ohne Datumspräfix)
|
|
||||||
- Keine Sonderzeichen außer Leerzeichen
|
|
||||||
- Eindeutig und verständlich, nicht generisch
|
|
||||||
|
|
||||||
Wenn das Dokument nicht eindeutig interpretierbar ist, beschreibe dies im Reasoning.
|
Wenn das Dokument nicht eindeutig interpretierbar ist, beschreibe dies im Reasoning.
|
||||||
|
|
||||||
|
**Ausgabeformat: Ausschließlich reines JSON-Objekt**
|
||||||
|
|
||||||
|
Antworte nur mit einem JSON-Objekt nach folgendem Schema:
|
||||||
|
- Keine Präambel, keine Erklärungen, keine Markdown-Codeblöcke
|
||||||
|
- `title` (erforderlich): Der ermittelte deutsche Titel nach obigem Schema
|
||||||
|
- `reasoning` (erforderlich): Absender und Betreff in je einem Satz begründen
|
||||||
|
- `date` (optional): Das ermittelte Datum im Format YYYY-MM-DD; auslassen, falls kein belastbares Datum ableitbar ist
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
{"title":"Stadtwerke Bochum Grundbesitzabgaben 2025","reasoning":"Absender ist Stadtwerke Bochum laut Briefkopf. Betreff ist die Jahresabrechnung der Grundbesitzabgaben 2025.","date":"2025-03-15"}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# AP-A Token-Tracking Fundament – Zusammenfassung
|
||||||
|
|
||||||
|
Dieses Dokument fasst alle Klassen, Methoden und Dateien zusammen, die im Zuge
|
||||||
|
von AP-A (Token- und Kosten-Tracking-Fundament der V3.3-Spezifikation, #74)
|
||||||
|
neu erstellt oder substanziell erweitert wurden.
|
||||||
|
|
||||||
|
## Schema-Migration
|
||||||
|
|
||||||
|
- `pdf-umbenenner-adapter-out/src/main/resources/db/migration/V2__token_tracking.sql`
|
||||||
|
- Sechs neue Spalten in `processing_attempt`:
|
||||||
|
`input_tokens`, `output_tokens`,
|
||||||
|
`cache_creation_input_tokens`, `cache_read_input_tokens`,
|
||||||
|
`price_input_per_token_nano_usd`, `price_output_per_token_nano_usd`.
|
||||||
|
- Neue Tabelle `model_price` mit Composite Primary Key
|
||||||
|
`(provider, model_name)`, NOT-NULL-Preisen, Currency-CHECK auf `'USD'`,
|
||||||
|
`updated_at`-Spalte.
|
||||||
|
- Zwei zusätzliche Indizes auf `processing_attempt`:
|
||||||
|
`idx_processing_attempt_started_at_provider_fp_model`,
|
||||||
|
`idx_processing_attempt_run_id_provider_model`.
|
||||||
|
- Default-Preise für gpt-4o-mini, gpt-4o, gpt-4.1*, gpt-5*, claude-haiku-4-5,
|
||||||
|
claude-sonnet-4-6 und claude-opus-4-7 (`ON CONFLICT DO NOTHING`).
|
||||||
|
|
||||||
|
## Application-Modul
|
||||||
|
|
||||||
|
### DTOs (`application/dto`)
|
||||||
|
- `AiUsageMetadata` – Token-Verbrauchsmetadaten mit `empty()`,
|
||||||
|
`hasAnyTokenData()`, `hasCacheTokens()`.
|
||||||
|
- `ModelPriceEntry` – Schreib-/Validierungs-DTO mit Wertgrenzen-Validierung im
|
||||||
|
Konstruktor.
|
||||||
|
- `ModelPriceView` – Lese-/Anzeige-DTO mit nullable `updatedAt` und
|
||||||
|
`invalidUpdatedAt`-Flag.
|
||||||
|
- `ModelPriceKey` – Composite-Key für Löschungen.
|
||||||
|
- `ModelPriceChangeSet` – atomarer Block aus Upserts und Deletions, defensive
|
||||||
|
Listen-Kopie.
|
||||||
|
|
||||||
|
### Cost-Komponenten (`application/cost`)
|
||||||
|
- `CostResult` – interpretierte Kosten-Anzeige mit Status-Flags.
|
||||||
|
- `CostCalculator` – `formatRow(...)` und `calculateAttempt(...)` (echt
|
||||||
|
implementiert), `formatTotal(...)` als Stub für AP-B.
|
||||||
|
|
||||||
|
### Ports (`application/port/out`)
|
||||||
|
- `ModelPriceRepository` – `findAll`, `findByProviderAndModelName`, `upsert`,
|
||||||
|
`delete`, `saveAllChanges`.
|
||||||
|
- `AiInvocationSuccess` (erweitert) – neues Feld `usageMetadata`.
|
||||||
|
|
||||||
|
### Use Cases (`application/usecase`)
|
||||||
|
- `DefaultManageModelPricesUseCase` – CRUD-Fassade mit ChangeSet-Konflikt-
|
||||||
|
validierung (vier Regeln) und Provider-Whitelist beim Upsert.
|
||||||
|
- `ModelPriceValidationException` – deutsche Validierungsfehler-Exception.
|
||||||
|
|
||||||
|
### Application-Service-Anpassungen
|
||||||
|
- `AiNamingService` (erweitert) – reicht `AiUsageMetadata` aus dem
|
||||||
|
`AiInvocationSuccess` als Token-Felder in den `AiAttemptContext` weiter.
|
||||||
|
- `DocumentProcessingCoordinator` (erweitert) –
|
||||||
|
- neuer optionaler Konstruktor mit `ModelPriceRepository` und
|
||||||
|
`headlessMode`-Flag.
|
||||||
|
- `loadPriceSnapshot(modelName)` lädt Snapshot-Preis pro Versuch; Lookup-
|
||||||
|
Fehler liefern leeren Snapshot ohne Attempt-Verlust.
|
||||||
|
- `buildAttempt(...)` befüllt jetzt Token- und Preis-Snapshot-Felder im
|
||||||
|
`ProcessingAttempt`.
|
||||||
|
|
||||||
|
### Domain-Anpassungen
|
||||||
|
- `AiAttemptContext` (erweitert) – vier nullable Token-Felder
|
||||||
|
(`inputTokens`, `outputTokens`, `cacheCreationInputTokens`,
|
||||||
|
`cacheReadInputTokens`); Backward-compatible Convenience-Konstruktor.
|
||||||
|
- `ProcessingAttempt` (erweitert) – sechs nullable Token-/Preis-Snapshot-
|
||||||
|
Felder; Convenience-Konstruktor und `withoutAiFields(...)` ohne Verhaltens-
|
||||||
|
änderung.
|
||||||
|
|
||||||
|
## Adapter-Out-Modul
|
||||||
|
|
||||||
|
- `SqliteConnectionFactory` (neu) – zentrale Connection-Factory; setzt
|
||||||
|
`PRAGMA journal_mode=WAL` und `PRAGMA busy_timeout=5000`.
|
||||||
|
Foreign-Key-Pragma wird bewusst nicht implizit gesetzt (Verhalten der
|
||||||
|
bisherigen `DriverManager.getConnection`-Stellen erhalten).
|
||||||
|
- `SqliteUnitOfWorkAdapter`, `SqliteProcessingAttemptRepositoryAdapter`,
|
||||||
|
`SqliteHistoryQueryAdapter`, `SqliteDocumentRecordRepositoryAdapter`
|
||||||
|
(jeweils geändert) – nutzen die neue Factory.
|
||||||
|
- `SqliteProcessingAttemptRepositoryAdapter.save()` (erweitert) –
|
||||||
|
INSERT um sechs neue Spalten erweitert, neue Hilfsmethode
|
||||||
|
`setNullableLong(...)`.
|
||||||
|
- `SqliteHistoryQueryAdapter.mapToProcessingAttempt(...)` (erweitert) –
|
||||||
|
liest die sechs neuen Spalten via `readNullableLong(...)`.
|
||||||
|
- `SqliteSchemaInitializationAdapter` (geändert) – erwartete Spalten/Indizes
|
||||||
|
bleiben am V1-Zielschema; Doc-Klarstellung, dass V2 additiv auf der Baseline
|
||||||
|
arbeitet.
|
||||||
|
- `SqliteModelPriceRepositoryAdapter` (neu) – `findAll`,
|
||||||
|
`findByProviderAndModelName`, `upsert`, `delete`, `saveAllChanges`
|
||||||
|
(UPSERT via `ON CONFLICT(provider, model_name) DO UPDATE`, transaktionaler
|
||||||
|
Batch). Lese-Mapping behandelt `DateTimeParseException` als
|
||||||
|
`invalidUpdatedAt`.
|
||||||
|
- `ModelPriceRepositoryException` (neu) – technischer JDBC-Fehler.
|
||||||
|
|
||||||
|
### KI-Adapter
|
||||||
|
- `AnthropicClaudeHttpAdapter` (geändert) – neue Methode
|
||||||
|
`extractTokenUsageFromResponse(JSONObject)` für `usage.input_tokens`,
|
||||||
|
`usage.output_tokens`, `usage.cache_creation_input_tokens`,
|
||||||
|
`usage.cache_read_input_tokens` mit Validierung (negativ, > 10 Mio.,
|
||||||
|
nicht-numerisch → NULL + WARN).
|
||||||
|
- `OpenAiHttpAdapter` (geändert) – analoge Methode mit Mapping
|
||||||
|
`prompt_tokens → input_tokens`, `completion_tokens → output_tokens`;
|
||||||
|
Cache-Felder bleiben null.
|
||||||
|
|
||||||
|
## GUI-Modul
|
||||||
|
|
||||||
|
### Neuer Tab "Modell-Preise"
|
||||||
|
- `adapter-in-gui/modelprices/GuiModelPriceManagementPort` (neu) –
|
||||||
|
Bridge-Port für GUI-Zugriff auf Modell-Preise.
|
||||||
|
- `adapter-in-gui/modelprices/GuiModelPricesTab` (neu) – TableView mit
|
||||||
|
editierbaren Preisspalten (`In/1M USD`, `Out/1M USD`), Lösch-Button mit
|
||||||
|
Bestätigungsdialog, Add-Dialog mit Provider-Auswahl, Speichern-Aktion über
|
||||||
|
`ModelPriceChangeSet`. Konvertierung Nano-USD ↔ `$/1M Tokens` mit
|
||||||
|
HALF-UP-Rundung; unbekannte Provider werden read-only mit Tooltip
|
||||||
|
angezeigt; `updatedAt = null` als "ungueltig".
|
||||||
|
|
||||||
|
### Anbindung im Workspace
|
||||||
|
- `GuiConfigurationEditorWorkspace` (geändert) – sechster Tab "Modell-
|
||||||
|
Preise" wird angelegt; neue Methode `warnIfActiveModelHasNoPriceEntry()`
|
||||||
|
zeigt vor dem Speichern eine deutsche Warnung an, wenn das aktuell
|
||||||
|
ausgewählte Modell keinen Preis-Eintrag besitzt.
|
||||||
|
- `GuiStartupContext` (geändert) – neues optionales Feld
|
||||||
|
`modelPriceManagementPort` mit Backward-Kompatibilität.
|
||||||
|
- `BootstrapRunner` (geändert) – neue Methode
|
||||||
|
`buildGuiModelPriceManagementPort()` und Helfer für die Verdrahtung;
|
||||||
|
Coordinator wird mit `ModelPriceRepository` und `headlessMode`-Flag
|
||||||
|
versorgt.
|
||||||
|
|
||||||
|
### History-Tab
|
||||||
|
- `GuiHistoryTab` (geändert) – drei zusätzliche Spalten in der
|
||||||
|
Versuchstabelle: Input-Tokens, Output-Tokens, Kosten. Cache-only-Versuche
|
||||||
|
zeigen "nur Cache-Tokens, keine Standardkosten"; fehlender Preis-Snapshot
|
||||||
|
führt zu "Preis fehlt"; Mikrobeträge als "< $0.0001"; Cache-Beteiligung
|
||||||
|
ergänzt Suffix "(ohne Cache-Anteil)".
|
||||||
|
|
||||||
|
### Summary-Banner
|
||||||
|
- `BatchRunSummaryBanner` (geändert) – aus einzeiliger HBox wurde eine
|
||||||
|
vierzeilige VBox: Status-Zeile, Token-Zeile, Kosten-Zeile, optionale
|
||||||
|
Cache-only-Zeile. Neue Record-Klasse `BatchRunTokenSummary` mit
|
||||||
|
`empty()`-Default; bestehende `update(Map)`-Aufrufer bleiben funktionsfähig.
|
||||||
|
|
||||||
|
## Testanpassungen
|
||||||
|
|
||||||
|
- `pdf-umbenenner-application/.../service/AiNamingServiceTest` und
|
||||||
|
`pdf-umbenenner-bootstrap/.../e2e/StubAiInvocationPort` – alte
|
||||||
|
`AiInvocationSuccess`-Konstruktoraufrufe um `AiUsageMetadata.empty()`
|
||||||
|
ergänzt.
|
||||||
|
- `SqliteSchemaInitializationAdapterTest.fall1_leereDb_processingAttemptHatAlleErwartetenSpalten`
|
||||||
|
prüft jetzt zusätzlich die sechs Token-/Preis-Spalten.
|
||||||
|
- `GuiAdapterSmokeTest.editorWorkspace_startStateShowsEmptyHeaderDefaultsAndOneTab`
|
||||||
|
erwartet jetzt sechs Tabs inkl. "Modell-Preise".
|
||||||
|
|
||||||
|
## Build und Verifizierung
|
||||||
|
|
||||||
|
- `mvn clean verify` läuft auf dem Reactor `pdf-umbenenner-parent` durch
|
||||||
|
(Tests grün auf allen Modulen).
|
||||||
|
- Commit `08ec021` auf `main` gepusht.
|
||||||
|
|
||||||
|
## Bewusst ausgesparte Bereiche (für AP-B / AP-C)
|
||||||
|
|
||||||
|
- `CostCalculator.formatTotal(...)` ist ein Stub und wirft
|
||||||
|
`UnsupportedOperationException`.
|
||||||
|
- `TokenStatisticsReadModelPort`, `QueryCostAnalysisFullUseCase`,
|
||||||
|
`QueryCostAnalysisHeaderOnlyUseCase`, `QueryRunSummaryUseCase`,
|
||||||
|
`SqliteTokenStatisticsReadModelAdapter` sind nicht enthalten.
|
||||||
|
- Summary-Banner zeigt aktuell `0/0` Tokens und `$0.0000` Kosten, da das
|
||||||
|
Read-Model erst in AP-B verdrahtet wird.
|
||||||
|
- CLI-Befehle für Modell-Preise (#99) und Modell-Combobox-Filter (#98)
|
||||||
|
sind AP-C.
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
# Architektur-Übersicht: Adapter-Out, CLI & Bootstrap
|
||||||
|
|
||||||
|
Diese Datei beschreibt die drei Module `pdf-umbenenner-adapter-out`, `pdf-umbenenner-adapter-in-cli`
|
||||||
|
und `pdf-umbenenner-bootstrap`: ihren Zweck, ihre Paketstruktur, die wichtigsten Klassen und die
|
||||||
|
Verdrahtungslogik beim Programmstart. Sie richtet sich an Entwickler, die in einem dieser Module
|
||||||
|
arbeiten wollen und noch keinen Überblick über das Projekt haben. Domain- und Application-Schicht
|
||||||
|
(Port-Verträge, fachliche Domänenobjekte, Use-Case-Interfaces) sind nicht Gegenstand dieses
|
||||||
|
Dokuments – sie sind in `docs/architecture/domain-overview.md` beschrieben. GUI-interne Ports und
|
||||||
|
die Struktur des GUI-Adapters finden sich in `docs/architecture/gui-overview.md`. Die hexagonale
|
||||||
|
Abhängigkeitsrichtung ist strikt: Adapter kennen Domain und Application, nicht umgekehrt. Adapter
|
||||||
|
dürfen außerdem nicht direkt voneinander abhängen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Modulzweck
|
||||||
|
|
||||||
|
### pdf-umbenenner-adapter-out
|
||||||
|
|
||||||
|
Enthält alle Outbound-Adapter-Implementierungen, also die konkreten technischen Lösungen für
|
||||||
|
sämtliche Outbound-Ports der Application. Dazu gehören: Dateisystemzugriff, PDF-Textextraktion
|
||||||
|
via PDFBox, SQLite-Persistenz (Schema, Repositories, Unit of Work), HTTP-Clients für zwei
|
||||||
|
KI-Provider-Familien (OpenAI-kompatibel und Anthropic nativ), Properties-Konfiguration inklusive
|
||||||
|
Legacy-Migration, dateibasierter Run-Lock sowie Systemuhr und SHA-256-Fingerprint.
|
||||||
|
|
||||||
|
### pdf-umbenenner-adapter-in-cli
|
||||||
|
|
||||||
|
Schlanker Inbound-Adapter für den kopflosen Batch-Betrieb. Enthält genau eine Klasse
|
||||||
|
(`SchedulerBatchCommand`), die den CLI-Einstiegspunkt bildet und ausschließlich über das
|
||||||
|
Inbound-Port-Interface an die Application delegiert. Keine eigene Fachlogik.
|
||||||
|
|
||||||
|
### pdf-umbenenner-bootstrap
|
||||||
|
|
||||||
|
Composition Root der Anwendung. Verantwortlich für: CLI-Argument-Parsing,
|
||||||
|
Konfigurationsauflösung und -validierung, Aufbau des vollständigen Objektgraphen (manuell, ohne
|
||||||
|
DI-Framework), Auswahl der aktiven KI-Adapter-Implementierung, Dispatch auf GUI- oder
|
||||||
|
Headless-Pfad sowie Exit-Code-Ableitung. Bootstrap ist die einzige Stelle, an der alle Module
|
||||||
|
zusammengeführt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Paketstruktur
|
||||||
|
|
||||||
|
### pdf-umbenenner-adapter-out
|
||||||
|
|
||||||
|
Wurzelpaket: `de.gecheckt.pdf.umbenenner.adapter.out`
|
||||||
|
|
||||||
|
| Unterpaket | Inhalt |
|
||||||
|
|-------------------------|-------------------------------------------------------------------------------------|
|
||||||
|
| `.ai` | HTTP-Adapter für OpenAI-kompatible Schnittstelle und Anthropic Messages API |
|
||||||
|
| `.clock` | Systemuhr-Adapter (`Instant.now()`) |
|
||||||
|
| `.configuration` | Properties-Laden, Multi-Provider-Parsing/-Validierung, Legacy-Migration |
|
||||||
|
| `.fingerprint` | SHA-256-Inhalts-Fingerprint |
|
||||||
|
| `.lock` | Dateibasierter Run-Lock |
|
||||||
|
| `.modelcatalog` | HTTP-Modellabruf für den GUI-Konfigurationseditor |
|
||||||
|
| `.pathcheck` | Pfadprüfung für den GUI-Editor |
|
||||||
|
| `.pdfextraction` | PDFBox-3.x-Adapter: Textextraktion und Seitenanzahl |
|
||||||
|
| `.prompt` | Prompt-Template-Lader |
|
||||||
|
| `.resourcecreation` | Anlegen von Ordnern und Dateien (korrigierende technische Tests) |
|
||||||
|
| `.sourcedocument` | Quellordner-Scanner (nicht rekursiv) |
|
||||||
|
| `.sqlite` | Schema-Initialisierung, Repositories, Unit of Work |
|
||||||
|
| `.targetcopy` | Zielkopie via Temp-Datei und atomarem Move |
|
||||||
|
| `.targetfolder` | Kollisionsfreier Zieldateiname, Umbenennung bestehender Zieldateien |
|
||||||
|
| `.validation` | API-Key-Auflösung aus Umgebungsvariablen (GUI-Editor) |
|
||||||
|
| `.bootstrap.validation` | `StartConfiguration`-Validierung vor Prozessstart |
|
||||||
|
|
||||||
|
### pdf-umbenenner-adapter-in-cli
|
||||||
|
|
||||||
|
Wurzelpaket: `de.gecheckt.pdf.umbenenner.adapter.in.cli`
|
||||||
|
|
||||||
|
Enthält ausschließlich `SchedulerBatchCommand` sowie die zugehörige `package-info.java`.
|
||||||
|
|
||||||
|
### pdf-umbenenner-bootstrap
|
||||||
|
|
||||||
|
Wurzelpaket: `de.gecheckt.pdf.umbenenner.bootstrap`
|
||||||
|
|
||||||
|
| Unterpaket | Inhalt |
|
||||||
|
|---------------------|-------------------------------------------------------------------------------------------------------|
|
||||||
|
| *(Wurzel)* | `PdfUmbenennerApplication` (main), `BootstrapRunner`, `AiProviderSelector` |
|
||||||
|
| `.adapter` | Bootstrap-interne Adapter: `Log4jProcessingLogger`, `GuiConfigurationPropertiesWriter`, `AiModelCatalogDispatcher` |
|
||||||
|
| `.singleinstance` | `SingleInstanceGuard` – Einzelinstanz-Schutz via Loopback-ServerSocket |
|
||||||
|
| `.startup` | `StartupMode`, `StartupArguments`, `CliArgumentParser` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Schlüsselklassen
|
||||||
|
|
||||||
|
Die folgenden Klassen sind für das Verständnis der drei Module zentral. FQN-Kürzel: `...` steht
|
||||||
|
jeweils für das Wurzelpaket des Moduls.
|
||||||
|
|
||||||
|
### Adapter-Out
|
||||||
|
|
||||||
|
#### KI-Adapter
|
||||||
|
|
||||||
|
- **`...ai.OpenAiHttpAdapter`** – implementiert `AiInvocationPort` für OpenAI-kompatible Endpunkte.
|
||||||
|
POST `{baseUrl}/v1/chat/completions`, Bearer-Authentifizierung, extrahiert
|
||||||
|
`choices[0].message.content`, klassifiziert HTTP-Fehler und Timeouts als
|
||||||
|
`AiInvocationTechnicalFailure`.
|
||||||
|
|
||||||
|
- **`...ai.AnthropicClaudeHttpAdapter`** – implementiert `AiInvocationPort` für die native
|
||||||
|
Anthropic Messages API. POST `/v1/messages`, Header `x-api-key` und `anthropic-version`,
|
||||||
|
konkateniert `text`-Content-Blöcke aus dem Antwort-Array.
|
||||||
|
|
||||||
|
Beide Adapter liefern denselben Domain-Typ (`NamingProposal`) und enthalten keinerlei
|
||||||
|
provider-spezifische Typen in öffentlichen Signaturen. Welche Implementierung aktiv ist, entscheidet
|
||||||
|
ausschließlich der Bootstrap (→ `AiProviderSelector`).
|
||||||
|
|
||||||
|
#### Modell-Katalog (GUI)
|
||||||
|
|
||||||
|
- **`...modelcatalog.ClaudeModelCatalogAdapter`** – `AiModelCatalogPort` für Claude,
|
||||||
|
GET `/v1/models` mit `x-api-key`.
|
||||||
|
|
||||||
|
- **`...modelcatalog.OpenAiCompatibleModelCatalogAdapter`** – `AiModelCatalogPort` für
|
||||||
|
OpenAI-kompatibel, GET `/v1/models` mit Bearer.
|
||||||
|
|
||||||
|
#### PDF-Extraktion
|
||||||
|
|
||||||
|
- **`...pdfextraction.PdfTextExtractionPortAdapter`** – PDFBox-3.x-Adapter. Alle technischen
|
||||||
|
Fehler werden als `PdfExtractionTechnicalError` zurückgegeben; es werden keine Exceptions
|
||||||
|
propagiert.
|
||||||
|
|
||||||
|
#### SQLite
|
||||||
|
|
||||||
|
- **`...sqlite.SqliteSchemaInitializationAdapter`** – Flyway-basierte Schema-Initialisierung
|
||||||
|
mit `V1__initial_schema.sql`. Drei-Fall-Strategie: leere Datenbank (Flyway führt das Skript
|
||||||
|
vollständig aus), bestehender Datenbestand ohne Flyway-History (Schema-Prüfung, datiertes
|
||||||
|
Backup, dann Baseline-Eintrag ohne Skriptausführung), regulärer Folgestart mit Flyway-History
|
||||||
|
(idempotenter Lauf). Foreign-Key-Durchsetzung via `SQLiteConfig.enforceForeignKeys(true)` auf
|
||||||
|
DataSource-Ebene, sodass jede neue Verbindung automatisch `PRAGMA foreign_keys = ON` erhält.
|
||||||
|
|
||||||
|
- **`...sqlite.SqliteUnitOfWorkAdapter`** – implementiert `UnitOfWorkPort`. Setzt
|
||||||
|
`autoCommit=false`, führt atomare Commits durch, rollt bei Fehlern zurück. Die innere
|
||||||
|
`TransactionOperations`-Implementierung wurde um `resetDocumentStatusForRetry(DocumentFingerprint)`
|
||||||
|
erweitert: setzt feldgenau `overall_status = 'READY_FOR_AI'`, `content_error_count = 0`,
|
||||||
|
`transient_error_count = 0`, `last_failure_instant = NULL`; alle anderen Felder und alle
|
||||||
|
`processing_attempt`-Einträge bleiben unangetastet.
|
||||||
|
|
||||||
|
- **`...sqlite.SqliteDocumentRecordRepositoryAdapter`** – Stammsatz pro SHA-256-Fingerprint
|
||||||
|
(Gesamtstatus, Fehlerzähler, Zieldateiname usw.).
|
||||||
|
|
||||||
|
- **`...sqlite.SqliteProcessingAttemptRepositoryAdapter`** – Versuchshistorie, referenziert
|
||||||
|
über Fingerprint. Enthält u. a. Provider-Identifikator, Modellname, Prompt-Identifikator,
|
||||||
|
KI-Rohantwort und finalen Zieldateinamen.
|
||||||
|
|
||||||
|
- **`...sqlite.SqliteHistoryQueryAdapter`** – implementiert `HistoryQueryPort`. Kapselt alle
|
||||||
|
lesenden Datenbankoperationen für den Historien-Tab: Übersicht (`loadOverview` mit
|
||||||
|
Sortierung `updated_at DESC, fingerprint ASC`, LIMIT 501-Strategie, case-insensitive
|
||||||
|
Freitextsuche via `LOWER()` mit Sonderzeichen-Escape für `%` und `_`), Stammsatz-Lookup
|
||||||
|
(`findRecordByFingerprint`) und Versuchshistorie (`findAttemptsByFingerprint`).
|
||||||
|
|
||||||
|
#### Konfiguration
|
||||||
|
|
||||||
|
- **`...configuration.PropertiesConfigurationPortAdapter`** – implementiert `ConfigurationPort`.
|
||||||
|
Lädt `config/application.properties` (oder einen `--config`-Override), parst via
|
||||||
|
`MultiProviderConfigurationParser`, löst API-Keys aus Umgebungsvariablen
|
||||||
|
(`OPENAI_COMPATIBLE_API_KEY`, `ANTHROPIC_API_KEY`).
|
||||||
|
|
||||||
|
- **`...configuration.LegacyConfigurationMigrator`** – erkennt alte Flat-Key-Konfigurationen
|
||||||
|
(Schlüssel wie `api.baseUrl`, `api.model`), legt eine `.bak`-Sicherung an und überführt den
|
||||||
|
Inhalt in das aktuelle Multi-Provider-Schema.
|
||||||
|
|
||||||
|
#### Prompt-Adapter
|
||||||
|
|
||||||
|
- **`...prompt.FilesystemPromptPortAdapter`** – implementiert `PromptPort`. Lädt das
|
||||||
|
Prompt-Template aus einer externen Datei und leitet den Identifikator aus dem Dateinamen ab.
|
||||||
|
Die neue Methode `savePrompt(String content)` schreibt den Inhalt atomar: temporäre Datei
|
||||||
|
im selben Verzeichnis anlegen (gleiche Partition), Inhalt in UTF-8 schreiben, dann
|
||||||
|
`ATOMIC_MOVE` zur Zieldatei. Kein stiller Fallback bei `AtomicMoveNotSupportedException`.
|
||||||
|
Der Pfad stammt aus der Adapter-internen Konfiguration, nicht aus dem Port-Aufruf.
|
||||||
|
|
||||||
|
#### Laufzeitinfrastruktur
|
||||||
|
|
||||||
|
- **`...lock.FilesystemRunLockPortAdapter`** – Lock-Datei mit PID-Inhalt. Wirft
|
||||||
|
`RunLockUnavailableException`, wenn die Datei bereits vorhanden ist. Release löscht die Datei
|
||||||
|
(best-effort).
|
||||||
|
|
||||||
|
- **`...clock.SystemClockAdapter`** – delegiert an `Instant.now()`.
|
||||||
|
|
||||||
|
- **`...fingerprint.Sha256FingerprintAdapter`** – SHA-256 über den Rohdatei-Inhalt. Fehler als
|
||||||
|
`FingerprintTechnicalError`.
|
||||||
|
|
||||||
|
#### Zieldatei
|
||||||
|
|
||||||
|
- **`...targetcopy.FilesystemTargetFileCopyAdapter`** – kopiert die Quelldatei zunächst in eine
|
||||||
|
`.tmp`-Datei, dann atomarer Move (Fallback: Standard-Move). Die Quelldatei wird in keinem Fall
|
||||||
|
verändert.
|
||||||
|
|
||||||
|
- **`...targetfolder.FilesystemTargetFolderAdapter`** – ermittelt einen kollisionsfreien
|
||||||
|
Zieldateinamen mit `(1)`, `(2)`-Suffix. Erkennt inhaltsidentische Duplikate via SHA-256.
|
||||||
|
|
||||||
|
#### Validierung vor Prozessstart
|
||||||
|
|
||||||
|
- **`...bootstrap.validation.StartConfigurationValidator`** – validiert die geladene
|
||||||
|
`StartConfiguration` auf Pflichtfelder, Wertebereiche, URI-Syntax und Pfadbedingungen.
|
||||||
|
Wird im Bootstrap-Headless-Pfad unmittelbar nach dem Laden der Konfiguration aufgerufen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Adapter-In-CLI
|
||||||
|
|
||||||
|
- **`...adapter.in.cli.SchedulerBatchCommand`** – einziger Inbound-Adapter für den Headless-Betrieb.
|
||||||
|
Nimmt einen `BatchRunContext` entgegen, delegiert an `BatchRunProcessingUseCase.execute()` und
|
||||||
|
gibt `BatchRunOutcome` zurück. Enthält keine eigene Fachlogik; die Verdrahtung mit dem
|
||||||
|
Use-Case-Interface erfolgt ausschließlich im Bootstrap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bootstrap
|
||||||
|
|
||||||
|
- **`...bootstrap.PdfUmbenennerApplication`** – `main`-Methode. Parst CLI-Argumente via
|
||||||
|
`CliArgumentParser`, bricht bei ungültiger Verwendung mit Exit-Code 1 ab, delegiert an
|
||||||
|
`BootstrapRunner.run()` und ruft abschließend `System.exit()` mit dem zurückgegebenen Code auf.
|
||||||
|
|
||||||
|
- **`...bootstrap.BootstrapRunner`** – Herzstück der Verdrahtung. Baut den Objektgraph für
|
||||||
|
Headless- und GUI-Pfad, dispatcht über `StartupMode`, enthält `buildProductionBatchUseCase()`
|
||||||
|
und `runHeadlessBatch()` als zentrale Kompositionsmethoden, liefert den Exit-Code zurück.
|
||||||
|
|
||||||
|
- **`...bootstrap.AiProviderSelector`** – einzige Stelle, an der `AiProviderFamily` auf eine
|
||||||
|
konkrete `AiInvocationPort`-Implementierung abgebildet wird:
|
||||||
|
`OPENAI_COMPATIBLE` → `OpenAiHttpAdapter`, `CLAUDE` → `AnthropicClaudeHttpAdapter`.
|
||||||
|
|
||||||
|
- **`...bootstrap.startup.CliArgumentParser`** – parst `--headless` und `--config <Pfad>` zu einem
|
||||||
|
typsicheren `StartupArgumentsParseResult` (sealed: `Valid` / `Invalid`).
|
||||||
|
|
||||||
|
- **`...bootstrap.singleinstance.SingleInstanceGuard`** – bindet einen Loopback-ServerSocket auf
|
||||||
|
Port 47832. Wirft `AnotherInstanceRunningException`, wenn der Port bereits belegt ist. Ein
|
||||||
|
Shutdown-Hook gibt den Socket frei.
|
||||||
|
|
||||||
|
- **`...bootstrap.adapter.AiModelCatalogDispatcher`** – Bootstrap-interner Dispatcher für die GUI.
|
||||||
|
Routet `AiModelCatalogPort`-Aufrufe anhand des `providerIdentifier` an den Claude- oder
|
||||||
|
OpenAI-kompatiblen Modell-Katalog-Adapter. Thread-safe.
|
||||||
|
|
||||||
|
- **`...bootstrap.ApplicationVersionProvider`** – statische Hilfsklasse ohne Zustand. Liest
|
||||||
|
`Implementation-Version` aus dem Paket-Manifest via `getClass().getPackage().getImplementationVersion()`.
|
||||||
|
Fallback `"dev"` bei IDE-Start und ungepacktem Betrieb (kein Manifest-Eintrag vorhanden).
|
||||||
|
Der aufgelöste Wert wird im GUI-Pfad in `GuiStartupContext.applicationVersion` eingesetzt.
|
||||||
|
|
||||||
|
- **`...bootstrap.adapter.Log4jProcessingLogger`** – implementiert `ProcessingLogger` auf Basis
|
||||||
|
von Log4j2. Unterdrückt sensitive KI-Inhalte, wenn `AiContentSensitivity.PROTECT_SENSITIVE_CONTENT`
|
||||||
|
gesetzt ist.
|
||||||
|
|
||||||
|
- **`...bootstrap.adapter.GuiConfigurationPropertiesWriter`** – schreibt die im GUI-Editor
|
||||||
|
bearbeitete Konfiguration als normalisierte `application.properties` zurück auf das Dateisystem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Verdrahtungslogik in Bootstrap
|
||||||
|
|
||||||
|
Die folgende Sequenz beschreibt den Ablauf von `main()` bis zum Start des eigentlichen Adapters.
|
||||||
|
Der Objektgraph wird ausschließlich durch manuelle `new`-Aufrufe aufgebaut; es wird kein
|
||||||
|
DI-Framework verwendet.
|
||||||
|
|
||||||
|
**Argument-Parsing**
|
||||||
|
- `PdfUmbenennerApplication.main()` → `CliArgumentParser.parse(args)`
|
||||||
|
- Ergebnis `Invalid` → Exit-Code 1, keine weiteren Schritte
|
||||||
|
|
||||||
|
**Einzelinstanz-Schutz**
|
||||||
|
- `BootstrapRunner.run()` → `SingleInstanceGuard.acquire()`
|
||||||
|
- `AnotherInstanceRunningException` → Exit-Code 1; im GUI-Modus zusätzlich ein Swing-Warndialog
|
||||||
|
|
||||||
|
**Modus-Dispatch**
|
||||||
|
- `BootstrapRunner.run()` wertet `startupArguments.mode()` aus:
|
||||||
|
- `HEADLESS` → `runHeadlessBatch()`
|
||||||
|
- `GUI` → `startGuiMode()`
|
||||||
|
|
||||||
|
**Konfigurationsauflösung (Headless-Pfad)**
|
||||||
|
- Prüfung, ob `--config`-Datei existiert (Fehler → Exit-Code 1)
|
||||||
|
- `LegacyConfigurationMigrator.migrateIfLegacy()` bei erkannter Legacy-Form
|
||||||
|
- `PropertiesConfigurationPortAdapter` lädt und parst die Properties
|
||||||
|
- `StartConfigurationValidator` validiert die geladene `StartConfiguration`
|
||||||
|
- Validierungsfehler → Exit-Code 1
|
||||||
|
|
||||||
|
**KI-Provider-Auswahl**
|
||||||
|
- Innerhalb von `buildProductionBatchUseCase()`:
|
||||||
|
`multiProviderConfiguration().activeProviderFamily()` → `AiProviderSelector.select(family, providerConfig)`
|
||||||
|
- Ergebnis: genau eine `AiInvocationPort`-Instanz
|
||||||
|
|
||||||
|
**Objektgraph-Aufbau (Headless)**
|
||||||
|
- Erzeugte Instanzen (Reihenfolge nach Abhängigkeit): `Sha256FingerprintAdapter`,
|
||||||
|
`SqliteDocumentRecordRepositoryAdapter`, `SqliteProcessingAttemptRepositoryAdapter`,
|
||||||
|
`SqliteUnitOfWorkAdapter`, `FilesystemTargetFolderAdapter`, `FilesystemTargetFileCopyAdapter`,
|
||||||
|
`FilesystemPromptPortAdapter`, `SystemClockAdapter`, `SourceDocumentCandidatesPortAdapter`,
|
||||||
|
`PdfTextExtractionPortAdapter`, `Log4jProcessingLogger`
|
||||||
|
- Application-Services (`DocumentProcessingCoordinator`, `AiResponseValidator`,
|
||||||
|
`AiNamingService`) werden verdrahtet und in `DefaultBatchRunProcessingUseCase` eingebettet
|
||||||
|
|
||||||
|
**CLI-Adapter**
|
||||||
|
- `BootstrapRunner` erzeugt `SchedulerBatchCommand` mit dem fertigen `BatchRunProcessingUseCase`
|
||||||
|
|
||||||
|
**Exit-Code-Ableitung**
|
||||||
|
- `BatchRunOutcome` → 0 (Lauf technisch erfolgreich) oder 1 (harter Bootstrap-/Konfigurationsfehler)
|
||||||
|
- `PdfUmbenennerApplication` ruft `System.exit(exitCode)` auf
|
||||||
|
|
||||||
|
**GUI-Pfad**
|
||||||
|
- `startGuiMode()` baut via `buildGuiStartupContext()` einen `GuiStartupContext`:
|
||||||
|
enthält `AiModelCatalogDispatcher`, `EnvironmentApiKeyResolutionAdapter`,
|
||||||
|
`TechnicalTestOrchestrator`, `GuiConfigurationPropertiesWriter`
|
||||||
|
- Bootstrap verdrahtet zusätzlich vier neue History-Use-Cases (`DefaultHistoryOverviewUseCase`,
|
||||||
|
`DefaultHistoryDetailsUseCase`, `DefaultHistoryResetDocumentStatusUseCase`,
|
||||||
|
`DefaultDeleteDocumentHistoryUseCase`) und den `DefaultPromptEditorUseCase` als anonyme
|
||||||
|
Bridge-Implementierungen in den `GuiStartupContext`
|
||||||
|
- `ApplicationVersionProvider.resolveVersion()` wird aufgerufen und der Wert in
|
||||||
|
`GuiStartupContext.applicationVersion` gesetzt
|
||||||
|
- Wenn eine Konfigurationsdatei beim Start bekannt ist, erzeugt Bootstrap zusätzlich einen
|
||||||
|
vollständig verdrahteten `GuiPromptEditorPort` (kombiniert `FilesystemPromptPortAdapter` mit
|
||||||
|
`DefaultPromptEditorUseCase`); ohne Konfiguration erhält der Context einen No-Op-Port
|
||||||
|
- `GuiAdapter.start(context)` übernimmt; ab diesem Punkt liegt die Kontrolle beim GUI-Adapter
|
||||||
|
- Im GUI-Pfad: keine SQLite-Schema-Initialisierung beim Start, kein Run-Lock-Erwerb, kein Batch-Use-Case;
|
||||||
|
History-Operationen initialisieren die Schema-Verbindung ad-hoc pro Aufruf
|
||||||
|
- GUI-interne Ports und deren Verbindung mit Outbound-Adaptern sind in
|
||||||
|
`docs/architecture/gui-overview.md` beschrieben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Einstiegspunkte je Modul
|
||||||
|
|
||||||
|
### pdf-umbenenner-adapter-out
|
||||||
|
|
||||||
|
1. **`...ai.OpenAiHttpAdapter`** – zeigt das typische Adapter-Muster: Port-Interface implementieren,
|
||||||
|
alle provider-spezifischen Details kapseln, `ProviderConfiguration` als einzige
|
||||||
|
Konfigurationsquelle konsumieren. Danach `AnthropicClaudeHttpAdapter` zum Vergleich lesen.
|
||||||
|
|
||||||
|
2. **`...sqlite.SqliteSchemaInitializationAdapter`** – erklärt das Datenbankschema, das alle
|
||||||
|
SQLite-Adapter voraussetzen. Hier sieht man, welche Felder in `document_record` und
|
||||||
|
`processing_attempt` existieren und wie Schema-Evolution additiv umgesetzt ist.
|
||||||
|
|
||||||
|
3. **`...configuration.PropertiesConfigurationPortAdapter`** – Einstieg in die
|
||||||
|
Konfigurationskette. Von hier aus `MultiProviderConfigurationParser` und
|
||||||
|
`LegacyConfigurationMigrator` nachverfolgen.
|
||||||
|
|
||||||
|
### pdf-umbenenner-adapter-in-cli
|
||||||
|
|
||||||
|
1. **`...adapter.in.cli.SchedulerBatchCommand`** – komprimiertes Inbound-Adapter-Muster in einer
|
||||||
|
einzigen Klasse. Zeigt, wie ein Inbound-Adapter ausschließlich über Port-Interfaces mit der
|
||||||
|
Application kommuniziert.
|
||||||
|
|
||||||
|
2. **`package-info.java`** – beschreibt Abhängigkeitsrichtung und Verdrahtungsvertrag dieses
|
||||||
|
Adapters.
|
||||||
|
|
||||||
|
3. **`SchedulerBatchCommandTest`** – zeigt, wie der Adapter ohne Bootstrap testbar ist.
|
||||||
|
|
||||||
|
### pdf-umbenenner-bootstrap
|
||||||
|
|
||||||
|
1. **`PdfUmbenennerApplication`** – Startpunkt; die kurze Kette von `main()` bis `System.exit()`
|
||||||
|
gibt einen ersten Überblick über die gesamte Startsequenz.
|
||||||
|
|
||||||
|
2. **`BootstrapRunner`** – Herzstück; `buildProductionBatchUseCase()` zeigt, wie der vollständige
|
||||||
|
Objektgraph manuell aufgebaut wird. `runHeadlessBatch()` zeigt den Headless-Kontrollfluss.
|
||||||
|
|
||||||
|
3. **`AiProviderSelector`** – kleinste Klasse mit größter Hebelwirkung: hier liegt die einzige
|
||||||
|
Stelle, an der die Provider-Auswahl aus der Konfiguration auf eine konkrete
|
||||||
|
`AiInvocationPort`-Implementierung trifft.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Port-Verträge und Domain-Typen: `docs/architecture/domain-overview.md`*
|
||||||
|
*GUI-interne Ports und GUI-Adapter-Struktur: `docs/architecture/gui-overview.md`*
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Architektur-Übersicht: Domain & Application
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt die fachliche und anwendungsnahe Schicht des PDF-Umbenenners: das Modul `pdf-umbenenner-domain` und das Modul `pdf-umbenenner-application`. Es richtet sich an Entwickler, die in diesen beiden Modulen arbeiten, und soll als alleiniger Architekturkontext ausreichen – ergänzt durch die `CLAUDE.md` im Projektroot. Nicht enthalten sind Adapter-Implementierungen (Dateisystem, PDFBox, SQLite, HTTP-Clients); diese sind in `adapter-overview.md` beschrieben. GUI-spezifische Ports und deren Einbettung in den Konfigurationseditor sind in `gui-overview.md` dokumentiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Modulzweck
|
||||||
|
|
||||||
|
### `pdf-umbenenner-domain`
|
||||||
|
|
||||||
|
Enthält ausschließlich fachliche Kerntypen (Records, Enums, Sealed Interfaces) ohne jegliche Infrastrukturabhängigkeiten. Alle Typen modellieren den Problembereich und sind von anderen Modulen referenzierbar, ohne transitive Abhängigkeiten einzuschleppen.
|
||||||
|
|
||||||
|
### `pdf-umbenenner-application`
|
||||||
|
|
||||||
|
Definiert Use-Case-Orchestrierung sowie alle Inbound- und Outbound-Ports der hexagonalen Architektur. Enthält anwendungsnahe Dienste (KI-Antwort-Parsing, Pre-Check-Auswertung, Retry-Entscheidung) und Konfigurationsmodelle, aber keinerlei Infrastrukturcode (kein JDBC, kein PDFBox, kein HTTP-Client, kein JavaFX).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Paketstruktur
|
||||||
|
|
||||||
|
### `pdf-umbenenner-domain`
|
||||||
|
|
||||||
|
| Paket | Verantwortung |
|
||||||
|
|-------|---------------|
|
||||||
|
| `de.gecheckt.pdf.umbenenner.domain` | Wurzelpaket; enthält nur `package-info.java` |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.domain.model` | Alle fachlichen Kerntypen: Records, Sealed Interfaces und Enums, die die Verarbeitungsdomäne beschreiben |
|
||||||
|
|
||||||
|
### `pdf-umbenenner-application`
|
||||||
|
|
||||||
|
| Paket | Verantwortung |
|
||||||
|
|-------|---------------|
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application` | Wurzelpaket des Application-Moduls |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.port.in` | Inbound-Ports (Use-Case-Interfaces) – Einstiegspunkte für den Aufrufer |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.port.out` | Outbound-Ports – Verträge gegenüber Infrastruktur-Adaptern (Persistenz, Dateisystem, KI, Uhr, Logging) |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog` | Spezialisierter Outbound-Port für den Abruf verfügbarer KI-Modelle; ausschließlich im GUI-Pfad genutzt (siehe `gui-overview.md`) |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.port.out.history` | Outbound-Port für lesende Historien-Abfragen aus dem Historien-Tab; bewusst getrennt von den bestehenden Repositories, um diese nicht mit GUI-spezifischen Methoden aufzublähen |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.service` | Anwendungsnahe, zustandslose Dienste: KI-Antwort-Parsing, Pre-Check-Auswertung, Verarbeitungs-Pipeline, Retry-Entscheidung |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.config` | Konfigurationsmodelle der Anwendungsschicht (`RuntimeConfiguration`, Provider-Konfiguration) |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.config.startup` | Vollständiges Startup-Konfigurationsmodell (`StartConfiguration`) |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.config.provider` | Modelle für KI-Provider-Konfiguration (Provider-Familie, Einzelkonfiguration, Multi-Provider) |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.validation.editor` | Validierungslogik für den GUI-Konfigurationseditor (Findings, Report, API-Key-Auflösung); siehe `gui-overview.md` |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.validation.technicaltest` | Technischer Selbsttest: Pfad-Checks, Korrekturpläne, Checkpoints; Details in `gui-overview.md` |
|
||||||
|
| `de.gecheckt.pdf.umbenenner.application.usecase` | Paket-Marker für Use-Case-Implementierungen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Schlüsselklassen
|
||||||
|
|
||||||
|
### Domain-Modul
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate`**
|
||||||
|
Record für einen PDF-Kandidaten aus dem Quellordner. Enthält keinen `Path`, sondern einen opaken `SourceDocumentLocator`, damit die Domain frei von NIO-Typen bleibt.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint`**
|
||||||
|
Record mit einem SHA-256-Hex-String (64 Zeichen) als stabiler Dokumentidentität; Grundlage für Idempotenz und Persistenz-Lookup.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome`**
|
||||||
|
Sealed Interface mit sechs Implementierungen, die alle möglichen Ausgänge der Dokumentverarbeitung exhaustiv abbilden:
|
||||||
|
|
||||||
|
| Implementierung | Bedeutung |
|
||||||
|
|-----------------|-----------|
|
||||||
|
| `PreCheckPassed` | Vorprüfung bestanden, KI-Pfad freigegeben |
|
||||||
|
| `PreCheckFailed` | Deterministischer Inhaltsfehler vor KI-Aufruf |
|
||||||
|
| `TechnicalDocumentError` | Technischer Fehler ohne erneuten KI-Aufruf |
|
||||||
|
| `NamingProposalReady` | KI-Antwort gültig, Vorschlag liegt vor |
|
||||||
|
| `AiTechnicalFailure` | Transienter technischer Fehler beim KI-Aufruf |
|
||||||
|
| `AiFunctionalFailure` | Deterministischer fachlicher Fehler der KI-Antwort |
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus`**
|
||||||
|
Enum mit acht Zuständen. Dokumentiert Zustandsübergänge und Retry-Schwellen; fachliches Herzstück der Persistenz-Semantik.
|
||||||
|
|
||||||
|
| Status | Bedeutung |
|
||||||
|
|--------|-----------|
|
||||||
|
| `READY_FOR_AI` | Verarbeitbar, KI-Pfad noch nicht durchlaufen |
|
||||||
|
| `FAILED_RETRYABLE` | Verarbeitbar, transient fehlgeschlagen |
|
||||||
|
| `PROPOSAL_READY` | Eingangszustand für Dateinamensbildung und Zielkopie |
|
||||||
|
| `SUCCESS` | Terminaler Enderfolg – nur nach Zielkopie und konsistenter Persistenz |
|
||||||
|
| `FAILED_FINAL` | Terminal, wird nicht erneut fachlich verarbeitet |
|
||||||
|
| `SKIPPED_ALREADY_PROCESSED` | Historisierter Skip für `SUCCESS`-Dokumente |
|
||||||
|
| `SKIPPED_FINAL_FAILURE` | Historisierter Skip für `FAILED_FINAL`-Dokumente |
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.domain.model.NamingProposal`**
|
||||||
|
Record mit aufgelöstem Datum, `DateSource`, validiertem Titel und KI-Begründung. Führende Quelle für die Zieldateinamensbildung.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext`**
|
||||||
|
Klasse mit Run-ID, Zeitstempel und optionalem Fingerabdruck-Filter; steuert den Umfang eines Batch-Laufs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Application-Modul
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration`**
|
||||||
|
Schmales Laufzeit-Record (`maxPages`, `maxRetriesTransient`, `aiContentSensitivity`). Wird von den Use Cases verwendet, enthält keine Pfade.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration`**
|
||||||
|
Vollständige typisierte Startup-Konfiguration; einziger Ort in der Anwendungsschicht, an dem `java.nio.file.Path` vorkommt. Wird vom `ConfigurationPort` geliefert und von Bootstrap ausgewertet.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingService`**
|
||||||
|
Statische Hilfsklasse: überführt ein Extraktionsergebnis über den Pre-Check in ein `DocumentProcessingOutcome`. Kompakte Pipeline-Klasse; guter Einstieg zum Verständnis der Verarbeitungslogik.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.service.AiResponseParser`**
|
||||||
|
Statischer Parser für KI-Antworten in `ParsedAiResponse`. Erzwingt reines JSON-Objekt; Validierungslogik liegt vollständig in der Anwendungsschicht.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt`**
|
||||||
|
Record für einen Versuchshistorie-Eintrag; enthält u. a. Provider-Identifikator, Modellname, Prompt-Identifikator, aufgelöstes Datum und finalen Zieldateinamen.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord`**
|
||||||
|
Record für den Dokument-Stammsatz; enthält Gesamtstatus, Fehler- und Transientzähler sowie letzten Zielpfad.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery`**
|
||||||
|
Record mit den Abfrageparametern für den Historien-Tab: optionaler Suchbegriff (`searchText`, Teilstring, case-insensitiv), optionaler Status-Filter (`statusFilter` als Enum-Name) und Limit der zurückzugebenden Zeilen (Standard `DEFAULT_LIMIT = 501`). Das Limit 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr als 500 Treffer vorhanden sind.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow`**
|
||||||
|
Einzelzeile der Dokumentenliste im Historien-Tab. Felder: `fingerprint`, `overallStatus`, `sourceFileName`, `targetFileName` (null wenn noch kein Erfolg), `sourcePath`, `updatedAt` und `attemptCount`. Stammt aus `document_record` mit einem `COUNT`-Ausdruck über `processing_attempt`.
|
||||||
|
|
||||||
|
**`de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult`**
|
||||||
|
Versiegeltes Ergebnis-Interface für `PromptPort.savePrompt(String)`. Zulässige Ausprägungen: `Saved` (Erfolg, enthält absoluten Pfad), `WriteFailed` (I/O-Fehler beim Schreiben der Temp-Datei), `TargetDirectoryMissing` (Zielordner fehlt), `AtomicMoveFailed` (atomares Verschieben nicht möglich; kein stiller Fallback).
|
||||||
|
|
||||||
|
**Neue Use-Case-Implementierungen im Paket `de.gecheckt.pdf.umbenenner.application.usecase`**
|
||||||
|
|
||||||
|
| Klasse | Zweck |
|
||||||
|
|--------|-------|
|
||||||
|
| `DefaultHistoryOverviewUseCase` | Lädt die gefilterte Dokumentenübersicht über `HistoryQueryPort.loadOverview`; gibt `HistoryOverviewResult` mit Liste und `hasMore`-Flag zurück |
|
||||||
|
| `DefaultHistoryDetailsUseCase` | Lädt Stammsatz und alle Verarbeitungsversuche für einen Fingerprint über `HistoryQueryPort`; gibt `HistoryDetailsResult` zurück |
|
||||||
|
| `DefaultHistoryResetDocumentStatusUseCase` | Feldgenauer Status-Reset via `UnitOfWorkPort.TransactionOperations.resetDocumentStatusForRetry`; setzt `overall_status`, `content_error_count`, `transient_error_count` und `last_failure_instant` zurück; lässt die Versuchshistorie unangetastet |
|
||||||
|
| `DefaultDeleteDocumentHistoryUseCase` | Löscht Stammsatz und alle Verarbeitungsversuche vollständig und transaktional via `UnitOfWorkPort` |
|
||||||
|
| `DefaultPromptEditorUseCase` | Delegiert Laden, Speichern und Standard-Anlegen der Prompt-Datei an `PromptPort` und `ResourceCreationPort`; wird im GUI-Pfad über `GuiPromptEditorPort` angesteuert |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Inbound Ports
|
||||||
|
|
||||||
|
### `BatchRunProcessingUseCase`
|
||||||
|
|
||||||
|
```
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase
|
||||||
|
```
|
||||||
|
|
||||||
|
Zentraler Use-Case-Einstiegspunkt für den gesamten Batch-Betrieb. Beschreibt den Anwendungszweck in einer einzigen Methode:
|
||||||
|
|
||||||
|
```java
|
||||||
|
BatchRunOutcome execute(BatchRunContext context);
|
||||||
|
```
|
||||||
|
|
||||||
|
Mögliche Ergebnisse:
|
||||||
|
|
||||||
|
| Ergebnis | Bedeutung |
|
||||||
|
|----------|-----------|
|
||||||
|
| `SUCCESS` | Lauf technisch ordnungsgemäß abgeschlossen |
|
||||||
|
| `LOCK_UNAVAILABLE` | Run-Lock konnte nicht erworben werden |
|
||||||
|
| `FAILURE` | Harter technischer Fehler beim Laufstart |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Outbound Ports
|
||||||
|
|
||||||
|
Alle Outbound-Ports liegen in `de.gecheckt.pdf.umbenenner.application.port.out` (bzw. dessen Unterpaket `modelcatalog`). Implementierungen befinden sich ausschließlich in `pdf-umbenenner-adapter-out`; Details dort sind in `adapter-overview.md` beschrieben.
|
||||||
|
|
||||||
|
| Interface | Zweck | Hauptmethode(n) |
|
||||||
|
|-----------|-------|-----------------|
|
||||||
|
| `SourceDocumentCandidatesPort` | Scannt Quellordner, liefert Kandidaten in deterministischer Reihenfolge | `List<SourceDocumentCandidate> loadCandidates()` |
|
||||||
|
| `FingerprintPort` | Berechnet SHA-256-Fingerabdruck eines Kandidaten | `FingerprintResult computeFingerprint(SourceDocumentCandidate)` |
|
||||||
|
| `PdfTextExtractionPort` | Extrahiert Text und Seitenanzahl aus einer PDF | `PdfExtractionResult extractTextAndPageCount(...)` |
|
||||||
|
| `AiInvocationPort` | Ruft den aktiven KI-Dienst auf; provider-neutral | `AiInvocationResult invoke(AiRequestRepresentation)` |
|
||||||
|
| `PromptPort` | Lädt das Prompt-Template aus der konfigurierten Quelle; speichert geänderten Inhalt atomar via `savePrompt(String)` – der Pfad stammt aus der Adapter-internen Konfiguration, nicht aus dem Port-Aufruf | `PromptLoadingResult loadPrompt()`, `PromptSaveResult savePrompt(String content)` |
|
||||||
|
| `TargetFileCopyPort` | Kopiert Quelldokument unter aufgelöstem Namen in den Zielordner (Temp + Rename) | `TargetFileCopyResult copyToTarget(...)` |
|
||||||
|
| `TargetFileRenamePort` | Atomare Umbenennung einer bereits kopierten Zieldatei (manuelle Korrektur) | `TargetFileRenameResult rename(...)` |
|
||||||
|
| `RunLockPort` | Exklusiver Lauf-Lock gegen parallele Instanzen | `acquire()` / `release()` |
|
||||||
|
| `PersistenceSchemaInitializationPort` | Idempotente Schema-Initialisierung der SQLite-Datenbank | `initializeSchema()` |
|
||||||
|
| `ClockPort` | Abstraktion des Systemtakts | `Instant now()` |
|
||||||
|
| `ConfigurationPort` | Lädt die typisierte Startup-Konfiguration | `StartConfiguration loadConfiguration()` |
|
||||||
|
| `ProcessingLogger` | Logging-Delegation; sensibles KI-Content-Logging über Flag gesteuert | `info/debug/warn/error/debugSensitiveAiContent(...)` |
|
||||||
|
| `AiModelCatalogPort` | Abruf verfügbarer Modelle vom Provider (nur GUI-Pfad, siehe `gui-overview.md`) | `ModelCatalogResult fetchAvailableModels(...)` |
|
||||||
|
| `PathCheckPort` | Lesende Pfad-Prüfung für den technischen Selbsttest | `isDirectoryReadable`, `isDirectoryWritableOrCreatable`, `isFileReadable`, `isSqlitePathUsable` |
|
||||||
|
| `ResourceCreationPort` | Schreibende Korrektur-Aktionen (Ordner anlegen, Prompt-Datei erzeugen, SQLite-Pfad vorbereiten) | `createDirectory`, `createPromptFile`, `prepareSqlitePath` |
|
||||||
|
| `ApiKeyResolutionPort` | Ermittelt API-Key-Herkunft pro Provider-Familie für die GUI-Validierung | `EffectiveApiKeyDescriptor resolve(...)` |
|
||||||
|
| `HistoryQueryPort` | Lesender Zugriff auf die Verarbeitungshistorie für den Historien-Tab; bewusst getrennt von den regulären Repositories | `List<DocumentHistoryRow> loadOverview(HistoryQuery)`, `Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint)`, `List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint)` |
|
||||||
|
|
||||||
|
> **Hinweis zu GUI-spezifischen Ports:** `AiModelCatalogPort`, `PathCheckPort`, `ResourceCreationPort`, `ApiKeyResolutionPort` und `HistoryQueryPort` werden ausschließlich im GUI-Pfad genutzt. Ihre Implementierungen und der Aufrufkontext sind in `gui-overview.md` bzw. `adapter-overview.md` beschrieben.
|
||||||
|
|
||||||
|
> **Hinweis zu `UnitOfWorkPort.TransactionOperations`:** Die innere Schnittstelle `TransactionOperations` wurde um die Methode `resetDocumentStatusForRetry(DocumentFingerprint)` erweitert. Diese setzt feldgenau `overall_status → READY_FOR_AI`, `content_error_count → 0`, `transient_error_count → 0` und `last_failure_instant → NULL`, ohne die Versuchshistorie zu berühren. Die Implementierung liegt in `SqliteUnitOfWorkAdapter`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Einstiegspunkte für neue Entwickler
|
||||||
|
|
||||||
|
Die folgende Lesereihenfolge gibt den kürzesten Weg zum Gesamtverständnis:
|
||||||
|
|
||||||
|
1. **`de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase`** – beschreibt den gesamten Anwendungszweck in einer Methode.
|
||||||
|
2. **`de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus`** – fachliches Herzstück; dokumentiert Zustandsübergänge und Retry-Schwellen.
|
||||||
|
3. **`de.gecheckt.pdf.umbenenner.domain.model`** (gesamtes Paket) – gemeinsame Sprache aller Schichten; vollständig in wenigen Minuten lesbar.
|
||||||
|
4. **`de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingService`** – kompakte Pipeline-Klasse; zeigt, wie Pre-Check und Ergebnis-Typen zusammenspielen.
|
||||||
|
5. **`de.gecheckt.pdf.umbenenner.application.port.out`** (gesamtes Paket) – vollständige Außengrenzen der Architektur; jeder Infrastrukturzugriff ist hier als Port definiert.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Architektur-Übersicht: GUI (adapter-in-gui)
|
||||||
|
|
||||||
|
Diese Datei beschreibt den Inbound-Adapter `pdf-umbenenner-adapter-in-gui` – die JavaFX-Desktop-GUI des PDF-Umbenenners. Sie ist zusammen mit `CLAUDE.md` im Projektroot als alleiniger Architekturkontext für GUI-Arbeit gedacht. Domain-Typen, Application-Ports und Outbound-Adapter (Dateisystem, SQLite, KI-HTTP) sind hier bewusst nicht beschrieben; dafür gelten `docs/architecture/domain-overview.md` und `docs/architecture/adapter-overview.md`. **Das JavaFX-Threading-Modell (Abschnitt 4) ist verbindlich und muss strikt eingehalten werden – GUI-Entwickler sollten diesen Abschnitt als erstes lesen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Modulzweck
|
||||||
|
|
||||||
|
`pdf-umbenenner-adapter-in-gui` ist der Inbound-Adapter für die Desktop-Oberfläche. Er:
|
||||||
|
|
||||||
|
- empfängt den Startaufruf von der Bootstrap-Schicht über `GuiAdapter`,
|
||||||
|
- baut das JavaFX-Hauptfenster auf,
|
||||||
|
- delegiert alle fachlichen und technischen Operationen an Bootstrap-seitig verdrahtete Ports,
|
||||||
|
- zeigt Ergebnisse ausschließlich auf dem JavaFX Application Thread an.
|
||||||
|
|
||||||
|
Das Modul enthält **keine fachliche Logik**, keinen Datenbankzugriff, keinen HTTP-Code und keine PDF-Verarbeitung. Es koordiniert lediglich Benutzereingaben, Worker-Threads und JavaFX-Controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Paketstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
de.gecheckt.pdf.umbenenner.adapter.in.gui
|
||||||
|
│
|
||||||
|
├── (root) Einstiegspunkt, Hauptfenster, Orchestrierung, GUI-interne
|
||||||
|
│ Ports, Hilfsklassen für Fenstertitel, System-Tray,
|
||||||
|
│ Dateiladen/-schreiben und Startkontext.
|
||||||
|
│ Enthält außerdem: GuiStatusBar, GuiPromptEditorTab und
|
||||||
|
│ GuiPromptEditorPort.
|
||||||
|
│
|
||||||
|
├── batchrun Komponenten für den Tab „Verarbeitungslauf":
|
||||||
|
│ Worker-Koordinator, Tab-Ansicht, Ergebniszeilen,
|
||||||
|
│ PDF-Vorschau, Dateiname-Editor sowie GUI-interne
|
||||||
|
│ Port-Interfaces für Batch-Run, Mini-Run, manuelles
|
||||||
|
│ Umbenennen/Kopieren, Status-Reset und historischen Kontext.
|
||||||
|
│ Enthält außerdem: BatchRunSummaryBanner und
|
||||||
|
│ ProcessingStatusPresentation.
|
||||||
|
│
|
||||||
|
├── editor View-Modell- und Zustandstypen ohne JavaFX-Controls
|
||||||
|
│ (Ausnahme: GuiModelFieldContainer). Enthält Snapshot,
|
||||||
|
│ Baseline/Current-Values, Dirty-State-Berechnung,
|
||||||
|
│ Provider-Konfigurationszustände, API-Key-Zustände,
|
||||||
|
│ Validierungsergebnisse, Meldungs- und Feldbefund-Typen.
|
||||||
|
│
|
||||||
|
└── history Komponenten für den Tab „Verlauf": Tab-Ansicht mit
|
||||||
|
zweigeteiltem Layout (Liste + Detail), Filter, Aktionen
|
||||||
|
(Status-Reset / vollständiges Löschen) sowie die vier
|
||||||
|
Bridge-Interfaces GuiHistoryOverviewPort,
|
||||||
|
GuiHistoryDetailsPort, GuiHistoryResetDocumentStatusPort
|
||||||
|
und GuiDeleteDocumentHistoryPort.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tab-Reihenfolge:** `Konfiguration | Verarbeitungslauf | Verlauf | Prompt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Schlüsselklassen
|
||||||
|
|
||||||
|
### Root-Paket
|
||||||
|
|
||||||
|
| Klasse (Kurzname) | Rolle |
|
||||||
|
|---|---|
|
||||||
|
| `GuiAdapter` | Einziger öffentlicher Bootstrap-Einstiegspunkt. Speichert `GuiStartupContext` im `GuiStartupContextHolder` und startet JavaFX via `Application.launch`. Genau einmal pro JVM aufrufbar. |
|
||||||
|
| `PdfUmbenennerGuiApplication` | JavaFX-`Application`-Unterklasse. Baut in `start(Stage)` Hauptfenster, `GuiConfigurationEditorWorkspace`, Titelaktualisierungs-Listener, Close-Handler und System-Tray auf. Triggert nach Anzeige `autoLoadLastConfiguration()`. |
|
||||||
|
| `GuiStartupContext` | Immutable Record mit allen Bootstrap-gelieferten Ports und Services: Dateilader/-schreiber, Modellkatalog-Port, API-Key-Resolution-Port, Technical-Test-Orchestrator, Correction-Execution-Service, Batch-Run-Launcher, Mini-Run-Launcher, Reset-Port, Manual-Rename-Port, Manual-Copy-Port, Historical-Context-Port, `applicationVersion` (Versions-String aus `ApplicationVersionProvider`), `promptEditorPort`, `historyOverviewPort`, `historyDetailsPort`, `historyResetDocumentStatusPort`, `deleteDocumentHistoryPort`. Bietet `blank()`-Fabrikmethode für Tests. |
|
||||||
|
| `GuiConfigurationEditorWorkspace` | Herzstück der Oberfläche. Baut `TabPane` mit den vier Tabs (Konfiguration, Verarbeitungslauf, Verlauf, Prompt), verwaltet `GuiConfigurationEditorState`, koordiniert Lade- und Schreibvorgänge auf Worker-Threads, steuert Dirty-State-Anzeige und Fenstertitel. |
|
||||||
|
| `GuiStatusBar` | Permanente Statuszeile am unteren Rand des Hauptfensters. Drei Segmente: links Anwendungsversion im Format `V<version>` (z. B. `Vdev`), Mitte aktiver Provider und Modell aus geladener Konfiguration, rechts Pfad der geladenen Konfigurationsdatei. Ohne geladene Konfiguration zeigen Mitte und Rechts den Platzhaltertext. |
|
||||||
|
| `GuiPromptEditorTab` | Tab „Prompt" mit `TextArea` zum Lesen, Bearbeiten und Speichern der Prompt-Datei. Dirty-State markiert den Tab-Titel mit einem Asterisk. Speichert atomar via `GuiPromptEditorPort`. Bietet „Auf Standard zurücksetzen" (füllt `TextArea` ohne zu speichern) und „Standard-Prompt erstellen" bei fehlender Datei. |
|
||||||
|
| `GuiModelCatalogCoordinator` | Asynchroner Modellabruf über `AiModelCatalogPort` auf Daemon-Thread `gui-model-catalog`. Liefert Ergebnis via `Platform.runLater` und aktualisiert ComboBox oder manuelles TextField. |
|
||||||
|
| `GuiTechnicalTestCoordinator` | Führt `TechnicalTestOrchestrator` asynchron auf Daemon-Thread `gui-technical-test` aus, ohne implizites Speichern. Replace-Semantik in `pendingMessages`. |
|
||||||
|
| `GuiCorrectionDialogCoordinator` | Empfängt `TechnicalTestReport`, leitet `CorrectionPlan` ab, zeigt gesammelten Bestätigungsdialog. Kein stiller Schreibzugriff. |
|
||||||
|
| `GuiUnsavedChangesGuard` | Drei-Wege-Schutzdialog (Speichern / Verwerfen / Abbrechen) vor Neu, Öffnen und Schließen. Dialog-Supplier ist injizierbar für Tests ohne Scene. |
|
||||||
|
| `SystemTrayManager` | Verwaltet Windows-System-Tray-Icon. Überbrückt AWT-EDT nach JavaFX via `Platform.runLater` für Stage-Operationen. |
|
||||||
|
|
||||||
|
### Paket `editor`
|
||||||
|
|
||||||
|
| Klasse (Kurzname) | Rolle |
|
||||||
|
|---|---|
|
||||||
|
| `GuiConfigurationEditorState` | Record mit `loadedFileSnapshot`, `baselineValues`, `values`, `pendingMigrationMessage`. Dirty-State wird per Vergleich berechnet, kein Flag. |
|
||||||
|
| `GuiConfigurationValues` | Hält alle editierbaren Konfigurationsfelder als JavaFX-freie Plain-Java-Typen. |
|
||||||
|
|
||||||
|
### Paket `batchrun`
|
||||||
|
|
||||||
|
| Klasse (Kurzname) | Rolle |
|
||||||
|
|---|---|
|
||||||
|
| `GuiBatchRunCoordinator` | Besitzt den Worker-Thread für Batch- und Mini-Run. Übersetzt `BatchRunProgressObserver`-Callbacks via `Platform.runLater`. Soft-Stop per `AtomicBoolean`. Enthält `toRow()` inkl. historischem Kontext-Nachladen. |
|
||||||
|
| `GuiBatchRunTab` | Zweiter Haupt-Tab mit `TableView`, `ProgressBar`, Start-/Stop-Buttons und Detailbereich. Implementiert `GuiBatchRunCoordinator.Listener`. |
|
||||||
|
| `BatchRunSummaryBanner` | Einzeilige Zusammenfassungsleiste nach Laufabschluss, unterhalb des Fortschrittsbalkens. Zeigt nur Kategorien mit Zähler > 0. Farbe ist niemals das einzige Unterscheidungsmerkmal: jedes Segment enthält Icon und Text. |
|
||||||
|
| `ProcessingStatusPresentation` | Zentrale Mapping-Klasse für Status-Icons, CSS-Farben, Tooltip-Texte und Summary-Kategoriebeschriften aller `DocumentCompletionStatus`-Werte. Einzige autoritative Quelle für alle Anzeigeorte (Tabelle, Detail, Banner). Enthält keine JavaFX-Typen; zustandslos und statisch. Farbe ist niemals das einzige Unterscheidungsmerkmal. |
|
||||||
|
| `PdfPreviewPane` | Asynchrones PDF-Rendering via PDFBox auf Single-Thread-Executor `pdf-preview-worker`. Stale-Request-Schutz via `AtomicLong`-Sequenznummer, In-Memory-Seiten-Cache. |
|
||||||
|
| `FileNameEditorPane` | Editor für den Zieldateinamen. Drei-Zustands-Modell: KI-Vorschlag / letzter gespeicherter / aktuelle Eingabe. Clientseitige Validierung; Speicher-Aufruf delegiert an Tab. |
|
||||||
|
| `AiFailureMessageTranslator` | Übersetzt englische technische Fehlermeldungen in deutsche Benutzertexte. Paket-privat, zustandslos. |
|
||||||
|
|
||||||
|
### Paket `history`
|
||||||
|
|
||||||
|
| Klasse (Kurzname) | Rolle |
|
||||||
|
|---|---|
|
||||||
|
| `GuiHistoryTab` | Tab „Verlauf" mit zweigeteiltem Layout: links Dokumentenliste mit Freitext- und Statusfilter, rechts Detailbereich mit Stammsatz und Versuchshistorie. Aktionen: Status-Reset (feldgenau, Versuchshistorie bleibt) und vollständiges Löschen (mit Bestätigungsdialog). Alle Datenbankoperationen auf Worker-Thread, UI-Updates via `Platform.runLater`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Threading-Modell
|
||||||
|
|
||||||
|
Das Modell ist verbindlich. Jede Verletzung dieser Regeln führt zu sporadischen `IllegalStateException`-Fehlern oder einer eingefrorenen Oberfläche.
|
||||||
|
|
||||||
|
### 4.1 Worker-Threads
|
||||||
|
|
||||||
|
Alle blockierenden Operationen laufen auf benannten Daemon-Threads außerhalb des JavaFX Application Thread.
|
||||||
|
|
||||||
|
| Thread-Name | Koordinator-Klasse | Operationen |
|
||||||
|
|---|---|---|
|
||||||
|
| `gui-batch-run` | `GuiBatchRunCoordinator` | Batch-Launcher, Mini-Run-Launcher, Reset-Port, historischer Kontext |
|
||||||
|
| `gui-model-catalog` | `GuiModelCatalogCoordinator` | `modelCatalogPort.fetchAvailableModels(...)` |
|
||||||
|
| `gui-technical-test` | `GuiTechnicalTestCoordinator` | `orchestrator.runTests(...)` |
|
||||||
|
| Korrektur-Worker (anonym) | `GuiCorrectionDialogCoordinator` | `correctionExecutionService.execute(...)` |
|
||||||
|
| `pdf-preview-worker` | `PdfPreviewPane` | `PDDocument` laden, `PDFRenderer.renderImageWithDPI`, `PDDocument.close` |
|
||||||
|
| Dateisystem-Worker (inline) | `GuiConfigurationEditorWorkspace` | `configurationFileLoader.load(...)`, `configurationFileWriter.write(...)` |
|
||||||
|
| Inline-Worker (anonym) | `GuiHistoryTab` | `historyOverviewPort.loadOverview(...)`, `historyDetailsPort.loadDetails(...)`, `historyResetDocumentStatusPort.resetStatus(...)`, `deleteDocumentHistoryPort.deleteHistory(...)` |
|
||||||
|
| Inline-Worker (anonym) | `GuiPromptEditorTab` | `promptEditorPort.loadCurrentPrompt()`, `promptEditorPort.save(...)`, `promptEditorPort.createDefaultPromptIfMissing(...)` |
|
||||||
|
|
||||||
|
### 4.2 JavaFX Application Thread
|
||||||
|
|
||||||
|
Alle Mutationen an JavaFX-Controls und alle Dialoganzeigen ausschließlich auf dem JavaFX Application Thread. Kein direktes Schreiben auf Controls vom Worker-Thread.
|
||||||
|
|
||||||
|
### 4.3 Übergangsmechanismus Worker → FX
|
||||||
|
|
||||||
|
Der Übergang erfolgt grundsätzlich via:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Platform.runLater(runnable);
|
||||||
|
```
|
||||||
|
|
||||||
|
Es werden **keine** `javafx.concurrent.Task` und kein `Service` verwendet. Die Koordinatoren steuern Threading manuell über zwei injizierbare Strategien:
|
||||||
|
|
||||||
|
| Injektionspunkt | Typ | Produktion | Tests |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `threadFactory` | `Function<Runnable, Thread>` | `Thread::new` (Daemon) | synchroner Direktaufruf |
|
||||||
|
| `fxDispatcher` | `Consumer<Runnable>` | `Platform::runLater` | synchroner Direktaufruf |
|
||||||
|
|
||||||
|
Durch diese Injektion sind Unit-Tests vollständig ohne JavaFX-Runtime möglich.
|
||||||
|
|
||||||
|
### 4.4 Stale-Request-Schutz
|
||||||
|
|
||||||
|
`PdfPreviewPane` vergibt für jede Renderanfrage eine inkrementelle `AtomicLong`-Sequenznummer. Ein abgeschlossenes Render-Ergebnis wird nur dann auf der UI angezeigt, wenn seine Sequenznummer noch der aktuellen entspricht. Veraltete Ergebnisse werden still verworfen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. GUI-interne Ports
|
||||||
|
|
||||||
|
> **Abgrenzung:** Die folgenden Interfaces sind **keine hexagonalen Outbound-Ports der Application-Schicht**. Sie sind modul-interne Brücken, über die `GuiAdapter` die Bootstrap-seitig verdrahteten Implementierungen in die GUI-Klassen einschleust. Die eigentlichen Application-Ports (`AiInvocationPort`, `AiModelCatalogPort` usw.) und deren Outbound-Adapter-Implementierungen sind in `docs/architecture/domain-overview.md` und `docs/architecture/adapter-overview.md` beschrieben.
|
||||||
|
|
||||||
|
### Root-Paket
|
||||||
|
|
||||||
|
| Interface | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `GuiConfigurationFileLoader` | Lädt eine `.properties`-Datei und liefert einen `GuiConfigurationEditorState`. Abstrahiert Migration und Bootstrap-Verdrahtung vom GUI-Code. |
|
||||||
|
| `GuiConfigurationFileWriter` | Schreibt aktuelle `GuiConfigurationValues` als normalisierte `.properties` inkl. Backup-Schema. |
|
||||||
|
|
||||||
|
### Paket `batchrun`
|
||||||
|
|
||||||
|
| Interface | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `GuiBatchRunLauncher` | Bootstrap-Brücke für den regulären Batch-Run auf dem Worker-Thread. |
|
||||||
|
| `GuiMiniRunLauncher` | Bootstrap-Brücke für einen auf einen Fingerprint-Filter beschränkten Mini-Run. |
|
||||||
|
| `GuiResetDocumentStatusPort` | Bootstrap-Brücke für den vollständigen Persistenz-Reset (Stammsatz und Versuchshistorie werden gelöscht) ohne Folge-Run. |
|
||||||
|
| `GuiManualFileRenamePort` | Bootstrap-Brücke für die manuelle Umbenennung der Zieldatei (Worker-Thread). |
|
||||||
|
| `GuiManualFileCopyPort` | Bootstrap-Brücke für die Kopie mit benutzerdefiniertem Zieldateinamen bei FAILED/SKIPPED-Dokumenten (Worker-Thread). |
|
||||||
|
| `GuiHistoricalDocumentContextPort` | Nachladen des vollständigen historischen Verarbeitungskontexts für übersprungene Dokumente (Worker-Thread). |
|
||||||
|
| `GuiHistoricalFileNamePort` | Spezialisierter Port für den letzten bekannten KI-Dateinamen. Weitgehend durch `GuiHistoricalDocumentContextPort` abgelöst, aber noch im Einsatz. |
|
||||||
|
|
||||||
|
### Root-Paket (GUI-interne Ports)
|
||||||
|
|
||||||
|
| Interface | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `GuiPromptEditorPort` | Bootstrap-Brücke zum Prompt-Editor-Use-Case: Laden (`loadCurrentPrompt()`), atomares Speichern (`save(String)`) und Standard-Anlegen (`createDefaultPromptIfMissing(...)`) der Prompt-Datei. |
|
||||||
|
|
||||||
|
### Paket `history`
|
||||||
|
|
||||||
|
| Interface | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `GuiHistoryOverviewPort` | Bootstrap-Brücke zur Historien-Übersicht; lädt gefilterte Dokumentenliste via `loadOverview(Path configFilePath, HistoryQuery)`. `configFilePath` ermöglicht der Bootstrap-Implementierung, die SQLite-Datenbank aus der aktuell geladenen Konfiguration abzuleiten. |
|
||||||
|
| `GuiHistoryDetailsPort` | Bootstrap-Brücke zur Detailansicht; lädt Stammsatz und alle Verarbeitungsversuche für einen Fingerprint via `loadDetails(Path, DocumentFingerprint)`. |
|
||||||
|
| `GuiHistoryResetDocumentStatusPort` | Bootstrap-Brücke für den feldgenauen Status-Reset im Historien-Tab (`overall_status → READY_FOR_AI`, Fehlerzähler → 0, `last_failure_instant → null`). Die Versuchshistorie bleibt vollständig erhalten. **Abgrenzung:** `GuiResetDocumentStatusPort` im `batchrun`-Paket löscht dagegen Stammsatz und Versuchshistorie vollständig. |
|
||||||
|
| `GuiDeleteDocumentHistoryPort` | Bootstrap-Brücke zum vollständigen Löschen von Stammsatz und Versuchshistorie via `deleteHistory(Path, DocumentFingerprint)`; destruktiv und nicht rückgängig zu machen. Die GUI zeigt vor dem Aufruf einen Bestätigungsdialog. |
|
||||||
|
|
||||||
|
Alle Implementierungen dieser Interfaces liegen in `pdf-umbenenner-bootstrap`. Das GUI-Modul kennt ausschließlich die Interface-Typen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Einstiegspunkte für neue Entwickler
|
||||||
|
|
||||||
|
Folgende Klassen und Dateien decken den schnellsten Einstieg ab:
|
||||||
|
|
||||||
|
1. **`GuiAdapter`** – Architekturgrenze zur Bootstrap-Schicht in zwei Methoden. Zeigt, wie die GUI aus Bootstrap-Sicht aufgerufen wird.
|
||||||
|
|
||||||
|
2. **`GuiStartupContext`** – Vollständige Liste aller Ports und Services, die Bootstrap in die GUI injiziert. Wer wissen will, was die GUI von außen bekommt, liest diesen Record.
|
||||||
|
|
||||||
|
3. **`GuiConfigurationEditorWorkspace`** – Zentrale UI-Klasse: Tab-Aufbau, Sektionen, Editor-Zustand, Dirty-State, Datei-I/O, Sub-Koordinatoren. Einstieg für alle Arbeiten am Konfigurationseditor-Tab.
|
||||||
|
|
||||||
|
4. **`GuiConfigurationEditorState` / `GuiConfigurationValues`** – View-Modell ohne JavaFX-Controls. Einstieg für alle Änderungen an editierbaren Konfigurationsfeldern und Dirty-State-Logik.
|
||||||
|
|
||||||
|
5. **`GuiBatchRunCoordinator`** – Threading-Modell in seiner reinsten Form: Worker-Thread, `Platform.runLater`-Übergabe, Soft-Stop, Listener-Protokoll. Einstieg für alle Arbeiten am Verarbeitungslauf-Tab.
|
||||||
|
|
||||||
|
6. **`batchrun/package-info.java`** – Kompakte Beschreibung des Threading-Kontrakts, der Abbruch-Semantik und der Konfigurationsquelle für dieses Paket.
|
||||||
|
|
||||||
|
### Querverweise
|
||||||
|
|
||||||
|
- Application-Ports und Domain-Typen (`NamingProposal`, `ProcessingStatus`, `DocumentFingerprint` usw.): `docs/architecture/domain-overview.md`
|
||||||
|
- Outbound-Adapter-Implementierungen (Dateisystem, SQLite, KI-HTTP, PDFBox) und Bootstrap-Verdrahtung: `docs/architecture/adapter-overview.md`
|
||||||
@@ -173,3 +173,183 @@ Spezifikationen (technik-und-architektur.md, fachliche-anforderungen.md, CLAUDE.
|
|||||||
vollständig umgesetzt und durch automatisierte Tests abgesichert. Der Maven-Build ist fehlerfrei.
|
vollständig umgesetzt und durch automatisierte Tests abgesichert. Der Maven-Build ist fehlerfrei.
|
||||||
Die CLAUDE.md-Naming-Convention-Regel (kein M1–M8, kein AP-xxx im Produktions- oder Testcode)
|
Die CLAUDE.md-Naming-Convention-Regel (kein M1–M8, kein AP-xxx im Produktions- oder Testcode)
|
||||||
ist vollständig eingehalten. Keine bekannten spezifikationsrelevanten Blocker sind offen.
|
ist vollständig eingehalten. Keine bekannten spezifikationsrelevanten Blocker sind offen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# V2.0-Gesamtstand – Integrierte Prüfung (Stand 2026-04-20)
|
||||||
|
|
||||||
|
**Prüfgrundlage:** Vollständiger Maven-Reactor-Build mit allen Tests (clean verify, `-DskipPitest=true`),
|
||||||
|
Code-Review gegen die verbindliche Spec-Trias (technik-und-architektur.md, fachliche-anforderungen.md,
|
||||||
|
CLAUDE.md), Sichtprüfung Dokumentation und Konfigurationsbeispiele.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build- und Testergebnisse
|
||||||
|
|
||||||
|
**Ausgeführtes Kommando:**
|
||||||
|
```
|
||||||
|
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make -DskipPitest=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gesamtergebnis: BUILD SUCCESS**
|
||||||
|
**Gesamtlaufzeit:** 01:18 min
|
||||||
|
**Datum:** 2026-04-20
|
||||||
|
|
||||||
|
| Modul | Tests | Failures | Errors | Skipped |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `pdf-umbenenner-domain` | 227 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-application` | 455 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-cli` | 8 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-gui` | 190 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-out` | 371 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-bootstrap` | 147 | 0 | 0 | 0 |
|
||||||
|
| **Gesamt** | **1.398** | **0** | **0** | **0** |
|
||||||
|
|
||||||
|
Alle Module bauen erfolgreich. Alle Tests bestehen. Das ausführbare Shade-JAR wird erzeugt und
|
||||||
|
enthält JavaFX (Win-Classifier), alle Module, PDFBox, SQLite-JDBC und Log4j2.
|
||||||
|
|
||||||
|
**Warnungen im Build:**
|
||||||
|
- `WARNING: A Java agent has been loaded dynamically (byte-buddy-agent)` – dies ist ein bekannter
|
||||||
|
Mockito/ByteBuddy-Hinweis, der in Tests auftritt. Kein funktionaler Defekt. Tritt seit V1.1 auf
|
||||||
|
und gilt als akzeptiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prüfpunkte gegen die V2.0-Spezifikation (1–20)
|
||||||
|
|
||||||
|
| Nr. | Prüfpunkt | Status | Begründung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | GUI ist Standardstart | **erfüllt** | `PdfUmbenennerApplication.main()` → `BootstrapRunner.run()` → `StartupMode.GUI` als Default; `--headless` erforderlich für Batch-Pfad. Korrekt implementiert und getestet (`BootstrapRunnerStartupDispatchTest`). |
|
||||||
|
| 2 | `--headless` erhalten, `--config` für beide Startarten | **erfüllt** | `CliArgumentParser` parst beide Optionen korrekt. `BootstrapRunnerConfigPathSemanticsTest` (10 Tests) prüft GUI/headless-Semantik für vorhandene und fehlende `--config`-Pfade. |
|
||||||
|
| 3 | `.properties` als einzige Konfigurationswahrheit | **erfüllt** | `PropertiesConfigurationPortAdapter` und `GuiConfigurationPropertiesWriter` schreiben/lesen ausschließlich `.properties`. Keine zweite Konfigurationswelt. |
|
||||||
|
| 4 | Zwei Provider (Claude, OpenAI-kompatibel), genau einer aktiv | **erfüllt** | `AiProviderSelector` wählt anhand `ai.provider.active`. `AiModelCatalogDispatcher` unterstützt beide Familien. Konfigurationsbeispiel zeigt beide Blöcke. Provider-Identifikator in Versuchshistorie persistiert (`ProviderIdentifierE2ETest`, 5 Tests). |
|
||||||
|
| 5 | Hexagonale Architektur (keine JavaFX in Domain/Application) | **erfüllt** | Grep-Prüfung: kein `import javafx` in `domain` oder `application`. `adapter-in-cli` und `adapter-out` ebenfalls JavaFX-frei. JavaFX ausschließlich in `adapter-in-gui`. |
|
||||||
|
| 6 | Threadingmodell (Worker für I/O, Platform.runLater für UI) | **erfüllt** | `GuiConfigurationEditorWorkspace`, `GuiModelCatalogCoordinator`, `GuiCorrectionDialogCoordinator`, `GuiTechnicalTestCoordinator` nutzen alle explizit `Platform.runLater` für UI-Updates. Worker-Thread-Trennung ist im Code nachweisbar. |
|
||||||
|
| 7 | Naming-Regel (keine M/AP/V-Bezeichner in Code) | **erfüllt** | Grep auf `M[0-9]+`, `AP-[0-9]+`, `V[12]\.[0-9]` in allen `src/main/java` und `src/test/java` liefert keine Treffer für neue V2.0-Module (`adapter-in-gui`, `bootstrap`-Erweiterungen). |
|
||||||
|
| 8 | JavaDoc-Standard | **erfüllt** | Alle neu hinzugefügten öffentlichen Klassen und Methoden haben Klassen- und Methoden-JavaDoc. `BootstrapRunner` und `PdfUmbenennerApplication` vollständig dokumentiert. `package-info.java` vorhanden in neuen Packages. |
|
||||||
|
| 9 | Dirty-State, Schutzdialog, Validierung, technische Tests | **erfüllt** | `GuiDirtyStateTest`, `GuiUnsavedChangesGuardSmokeTest`, `GuiValidateActionSmokeTest`, `GuiTechnicalTestCoordinatorSmokeTest` belegen die Kernpfade. `GuiUnsavedChangesGuard` kapselt die Dirty-State-Logik. |
|
||||||
|
| 10 | Meldungsbereich mit vier Stufen, feldnahe Fehlermeldungen | **erfüllt** | `GuiMessageSeverity` (INFO, HINWEIS, WARNUNG, FEHLER), `GuiMessageEntry` und `GuiFieldFinding` implementieren die Anforderungen. `GuiMessageAreaSmokeTest` und `GuiMessageEntryTest` prüfen sie. |
|
||||||
|
| 11 | Modellabruf, Manueller Modellfallback, Verwerfen-Regel | **erfüllt** | `GuiModelCatalogCoordinator` implementiert automatischen Abruf bei Providerwechsel, ComboBox vs. Textfeld-Umschaltung, und Verwerfen-Regel bei Listenwechsel. `GuiModelCatalogSmokeTest` prüft die Kernpfade. |
|
||||||
|
| 12 | Korrekturhilfen mit gesammeltem Bestätigungsdialog | **erfüllt** | `GuiCorrectionDialogCoordinator` sammelt Korrekturen und steuert den Bestätigungsdialog. `ConfirmationDialogContent` kapselt die Dialog-Inhalte. `GuiCorrectionDialogCoordinatorSmokeTest` prüft den Ablauf. |
|
||||||
|
| 13 | Automatische Prompt-Erzeugung | **erfüllt** | `DefaultPromptTemplate.defaultContent()` in `application`-Schicht; `FilesystemResourceCreationAdapter` erzeugt die Datei. `DefaultPromptTemplateTest` und `FilesystemResourceCreationAdapterTest` prüfen Inhalt und Erzeugung. Beispiel in `docs/examples/prompt.txt` konsistent. |
|
||||||
|
| 14 | Windows-Pfade und gemappte Laufwerke | **erfüllt** | `FilesystemPathCheckAdapter` akzeptiert Windows-Laufwerksbuchstaben. `FilesystemPathCheckAdapterTest` (28 Tests) enthält Windows-Pfad-Szenarien. Dokumentation in `betrieb.md` und `gui-bedienanleitung.md` ausdrücklich erwähnt. |
|
||||||
|
| 15 | Legacy-Migration mit `.bak`-Sicherung | **erfüllt** | `LegacyConfigurationMigrator` in `adapter-out`; GUI-Pfad ruft `detectedLegacyConfiguration` + `migrateConfigurationIfNeeded` in `BootstrapRunner` auf. `GuiConfigurationPropertiesWriterTest` prüft Backup-Schema. |
|
||||||
|
| 16 | Keine neuen Provider über Claude/OpenAI-kompatibel hinaus | **erfüllt** | Codebase enthält ausschließlich `ClaudeAiInvocationAdapter` und `OpenAiCompatibleAiInvocationAdapter`. Kein dritter Provider. |
|
||||||
|
| 17 | Keine neuen Distributionsformate (EXE/Installer) | **erfüllt** | `pom.xml` des Bootstrap-Moduls nutzt ausschließlich `maven-shade-plugin`. Kein `launch4j`, kein `jpackage`, kein Installer. |
|
||||||
|
| 18 | Kein manueller Verarbeitungslauf aus GUI (abgelöst ab V2.1) | **erfüllt** | `adapter-in-gui` enthält keine Klasse, die `BatchRunProcessingUseCase` aus einem GUI-Event aufruft. Kein „Start"-Button, keine Batch-Ausführungslogik im GUI-Adapter. |
|
||||||
|
| 19 | Keine DB-/Historienanzeige | **erfüllt** | Kein SQLite-Lesepfad aus `adapter-in-gui`. Kein Historien-Tab. Kein Ergebnis-Browser. |
|
||||||
|
| 20 | Keine fachlichen Änderungen an Kernverarbeitung | **erfüllt** | `DefaultBatchRunProcessingUseCase`, `DocumentProcessingCoordinator`, `AiNamingService`, `AiResponseValidator` sind gegenüber dem V1.1-Freigabestand unverändert. E2E-Tests (`BatchRunEndToEndTest`, 11 Szenarien) sind alle grün. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dokumentations-Vollständigkeitsprüfung
|
||||||
|
|
||||||
|
| Dokument | Status | Bewertung |
|
||||||
|
|---|---|---|
|
||||||
|
| `docs/betrieb.md` | vollständig | Alle V2.0-Themen abgedeckt: GUI-Standardstart, `--headless`, `--config`-Semantik für beide Modi, Plattformhinweis Windows, gemappte Laufwerke, GUI-Umfangsbegrenzung, Build- und Packaging-Abschnitt, JavaFX-Integration, headless ohne JavaFX-Initialisierung |
|
||||||
|
| `docs/gui-bedienanleitung.md` | vollständig | Alle AP-002-Themen abgedeckt: Startzustände (Abschnitte 2.1–2.4), alle 7 Aktionen (Abschnitte 4.1–4.7), vier Meldungsstufen (Abschnitt 3.2), feldnahe Fehlermeldungen (Abschnitt 5), Provider-Bedienung und Modellabruf (Abschnitt 7), API-Key-Auflösungsreihenfolge (Abschnitt 10), Dirty-State (Abschnitt 8), `.bak`-Sicherung und Legacy-Migration (Abschnitt 9), Windows-Hinweise (Abschnitt 12), bekannte Einschränkungen (Abschnitt 13) |
|
||||||
|
| `docs/examples/application.properties` | vollständig und konsistent | Alle Parameter des V2.0-Schemas vorhanden (beide Provider-Blöcke, alle Pflicht- und Optionalparameter). Kommentare zu Warnschwellen für `max.text.characters` enthalten. Default-Provider `claude` gesetzt (alphabetisch erster). Konsistent mit `GuiConfigurationTemplateFactory`. |
|
||||||
|
| `docs/examples/prompt.txt` | vollständig und konsistent | Deutschsprachiger Standardprompt. Inhaltlich identisch mit dem, was `DefaultPromptTemplate.defaultContent()` erzeugt (durch `FilesystemResourceCreationAdapterTest` nachgewiesen). JSON-Schema-Anforderungen (title, reasoning, date optional) abgebildet. |
|
||||||
|
| `README.md` | vollständig | V2.0-Hinweis im Header, GUI-Standardstart dokumentiert, `--headless` und `--config`-Beispiele vorhanden, Modul `pdf-umbenenner-adapter-in-gui` aufgelistet, Verweis auf `betrieb.md` und `gui-bedienanleitung.md`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release-Blocker
|
||||||
|
|
||||||
|
**Keine Release-Blocker identifiziert.**
|
||||||
|
|
||||||
|
Der vollständige Maven-Reactor-Build ist grün (1.398 Tests, 0 Failures, 0 Errors, 0 Skipped).
|
||||||
|
Alle 20 Prüfpunkte gegen die Spec-Trias sind als erfüllt bewertet. Die Dokumentation ist
|
||||||
|
vollständig und konsistent mit dem Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht blockierende Restpunkte
|
||||||
|
|
||||||
|
#### R1 – ByteBuddy-Agent-Warnung bei Tests
|
||||||
|
|
||||||
|
**Thema:** Testqualität / Laufzeitwarnung
|
||||||
|
**Befund:** Beim Build erscheint wiederholt `WARNING: A Java agent has been loaded dynamically
|
||||||
|
(byte-buddy-agent-1.14.12.jar)`. Der Hinweis stammt von Mockito und tritt seit dem V1.1-Stand auf.
|
||||||
|
Er ist nicht neu, betrifft nur die Testlaufzeit und hat keinen funktionalen Einfluss auf das
|
||||||
|
produzierte Artefakt.
|
||||||
|
**Bewertung:** Kein Handlungsbedarf. Mit `-XX:+EnableDynamicAgentLoading` unterdrückbar, aber
|
||||||
|
keine Pflicht für V2.0.
|
||||||
|
|
||||||
|
#### R2 – GUI-Tests ohne echten JavaFX-Rendering-Pfad
|
||||||
|
|
||||||
|
**Thema:** Testtiefe GUI
|
||||||
|
**Befund:** Die GUI-Tests (`GuiAdapterSmokeTest`, `GuiEditorRegressionSmokeTest` usw.) laufen
|
||||||
|
unter headless JavaFX (Monocle) und prüfen View-Modell-Logik, Zustandsübergänge und
|
||||||
|
Koordinatoren-Verhalten. Das visuelle Rendering der Oberfläche (Farbgebung der Meldungspräfixe,
|
||||||
|
Layout-Details) ist nicht automatisiert geprüft. Dies entspricht der in CLAUDE.md definierten
|
||||||
|
GUI-Teststrategie (kein TestFX über Monocle hinaus) und ist keine Abweichung vom Ziel.
|
||||||
|
**Bewertung:** Kein Handlungsbedarf. Entspricht der Teststrategie-Vorgabe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewusst außerhalb V2.0 liegende Themen (V2.1+)
|
||||||
|
|
||||||
|
Die folgenden Themen wurden im V2.0-Umfang nachweislich **nicht** implementiert und sind
|
||||||
|
ausdrücklich für spätere Ausbaustufen vorgesehen:
|
||||||
|
|
||||||
|
- **Manueller Verarbeitungslauf aus der GUI** (V2.1+) – **umgesetzt ab V2.1** (Tab „Verarbeitungslauf")
|
||||||
|
- **DB-/Historienansicht** in der GUI (V2.x+)
|
||||||
|
- **Kosten-Tracking** und Token-/Preisberechnung (V2.x+)
|
||||||
|
- **EXE-Wrapper / Installer** (V3+) – **umgesetzt ab V3**: EXE-Wrapper (M14), MSI-Installer (M15)
|
||||||
|
- **Weitere KI-Provider** über Claude und OpenAI-kompatibel hinaus (V3+)
|
||||||
|
- **Automatischer Fallback zwischen Providern** (V3+)
|
||||||
|
- **Profilverwaltung mit mehreren Konfigurationen je Provider** (V3+)
|
||||||
|
- **Plattformübergreifender offizieller GUI-Support** (V3+)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gesamtbewertung V2.0-Stand
|
||||||
|
|
||||||
|
| Klassifikation | Anzahl | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| Release-Blocker | **0** | – |
|
||||||
|
| Nicht blockierende Restpunkte | **2** | R1 ByteBuddy-Warnung, R2 Testtiefe GUI-Rendering |
|
||||||
|
| Bewusst außerhalb V2.0 | **8** | Manueller Lauf, Historienansicht, Kosten-Tracking, EXE, weitere Provider, Fallback, Profile, Cross-Platform |
|
||||||
|
|
||||||
|
**Build:** ERFOLGREICH · 1.398 Tests · 0 Failures · 0 Errors · Laufzeit 01:18 min
|
||||||
|
**Alle 20 Spezifikations-Prüfpunkte:** erfüllt
|
||||||
|
**Dokumentation:** vollständig und konsistent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# V2.9-Fixes (Stand 2026-04-24)
|
||||||
|
|
||||||
|
Die folgenden Issues wurden nach dem V2.0-Abschluss behoben und sind im aktuellen Stand integriert.
|
||||||
|
|
||||||
|
| Issue | Titel | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| #27 | Mausrad-Seitenwechsel und zuverlässiger Seitenanfang in PDF-Vorschau | **behoben** |
|
||||||
|
| #28 | Anwendung standardmäßig im Vollbild starten | **behoben** |
|
||||||
|
| #29 | Eigenes PDF-Rendering mit PDFBox statt PDFViewFX | **behoben** |
|
||||||
|
| #33 | Letzte Konfigurationsdatei beim Neustart automatisch laden | **behoben** |
|
||||||
|
|
||||||
|
### Beschreibung der Fixes
|
||||||
|
|
||||||
|
**#27 / #29 – PDF-Vorschau-Stabilität und PDFBox-Migration:**
|
||||||
|
Mehrere aufeinanderfolgende Fixes stabilisierten die PDF-Vorschau. Zunächst wurden
|
||||||
|
Scroll-Schutz und zuverlässiger Seitenanfang per ImageView-Listener verbessert.
|
||||||
|
Im letzten Schritt (#29) wurde die externe PDFViewFX-Abhängigkeit vollständig
|
||||||
|
durch direktes Rendering via `PDFRenderer.renderImageWithDPI` (Apache PDFBox, 120 DPI)
|
||||||
|
ersetzt. Lazy Rendering mit In-Memory-Cache und das „latest preview request wins"-Prinzip
|
||||||
|
blieben erhalten.
|
||||||
|
|
||||||
|
**#28 – Vollbild-Start:**
|
||||||
|
`stage.setMaximized(true)` in `PdfUmbenennerGuiApplication.start()` sorgt dafür, dass
|
||||||
|
das Fenster beim Start automatisch maximiert wird.
|
||||||
|
|
||||||
|
**#33 – Letzte Konfiguration automatisch laden:**
|
||||||
|
`GuiConfigurationEditorWorkspace` speichert den Pfad einer erfolgreich geladenen
|
||||||
|
Konfigurationsdatei in `java.util.prefs.Preferences` (Schlüssel `lastConfigPath`).
|
||||||
|
Beim nächsten Start wird diese Datei automatisch geladen, sofern sie noch existiert.
|
||||||
|
Fehlt die Datei, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Hinweis:** Für die Ausbaustufen V2.1 bis V2.9 wurden keine separaten Befundlisteneinträge
|
||||||
|
> erstellt. Befunde, Fixes und Verbesserungen dieser Stufen sind in den Gitea-Issues dokumentiert.
|
||||||
|
|||||||
+545
-14
@@ -8,13 +8,139 @@ und legt eine Kopie im konfigurierten Zielordner ab. Die Quelldatei bleibt unver
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Startmodi und Betriebsmodell (V2.0)
|
||||||
|
|
||||||
|
Ab V2.0 enthält die Anwendung zwei Startmodi in **einem gemeinsamen ausführbaren JAR**:
|
||||||
|
|
||||||
|
| Startmodus | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **GUI-Start** (Standard) | Öffnet die JavaFX-Desktop-GUI. Wird verwendet, wenn kein `--headless` angegeben ist. |
|
||||||
|
| **headless Betrieb** | Klassischer Batch-/Scheduler-Betrieb ohne grafische Oberfläche. Wird über `--headless` aktiviert. |
|
||||||
|
|
||||||
|
### CLI-Optionen
|
||||||
|
|
||||||
|
| Option | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| *(keine Argumente)* | GUI-Standardstart |
|
||||||
|
| `--headless` | Aktiviert den headless Batch-Betrieb (wie vor V2.0) |
|
||||||
|
| `--config <pfad>` | Zeigt explizit auf eine `.properties`-Konfigurationsdatei (für GUI und headless) |
|
||||||
|
|
||||||
|
`--config` und `--headless` können kombiniert werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
java -jar pdf-umbenenner-bootstrap-*.jar --headless --config C:\Pfad\zur\config.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verhalten bei fehlender oder ungültiger `--config`-Datei
|
||||||
|
|
||||||
|
| Startmodus | Datei nicht vorhanden | Datei vorhanden, aber ungültig |
|
||||||
|
|---|---|---|
|
||||||
|
| **headless** | Harter Startfehler, Exit-Code `1`, kein Fallback | Harter Startfehler, Exit-Code `1` |
|
||||||
|
| **GUI** | Fehlermeldung in der GUI, danach Verhalten wie ohne `--config` (Willkommenstext) | Fehlermeldung in der GUI, Konfiguration nicht geladen |
|
||||||
|
|
||||||
|
Im headless Betrieb ist ein nicht vorhandener `--config`-Pfad ein **harter Startfehler**. Ein stiller
|
||||||
|
Fallback auf das Default-Verhalten ist in diesem Fall ausdrücklich unzulässig.
|
||||||
|
|
||||||
|
### Verhalten bei GUI-Startfehlern
|
||||||
|
|
||||||
|
Tritt vor der erfolgreichen Anzeige der grafischen Oberfläche ein nicht behebbarer Fehler auf
|
||||||
|
(z. B. fehlende JavaFX-Laufzeit, Bootstrap-Fehler), beendet sich die Anwendung mit Exit-Code `1`.
|
||||||
|
|
||||||
|
### Plattform und Laufwerksbuchstaben
|
||||||
|
|
||||||
|
Die GUI wird **offiziell nur unter Windows** unterstützt. Der headless Betrieb bleibt für den
|
||||||
|
Windows Server-Betrieb geeignet.
|
||||||
|
|
||||||
|
Gemappte Netzlaufwerke wie `S:\` oder `H:\` werden ausdrücklich unterstützt. Eine Ablehnung
|
||||||
|
solcher Pfade allein wegen eines dahinterliegenden UNC-Pfads ist unzulässig.
|
||||||
|
|
||||||
|
### Startverhalten der GUI
|
||||||
|
|
||||||
|
Die GUI startet **maximiert** (Vollbild). Beim Start wird die zuletzt geladene
|
||||||
|
Konfigurationsdatei automatisch geladen. Der Pfad wird in den Windows-Benutzereinstellungen
|
||||||
|
gespeichert (`java.util.prefs.Preferences`). Existiert die Datei beim nächsten Start nicht
|
||||||
|
mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
||||||
|
|
||||||
|
### Umfang der GUI
|
||||||
|
|
||||||
|
Die GUI enthält fünf Tabs:
|
||||||
|
|
||||||
|
- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für
|
||||||
|
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
|
||||||
|
Prompt-Datei).
|
||||||
|
- **Tab „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit
|
||||||
|
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument. Pro Zeile ist eine
|
||||||
|
**integrierte PDF-Vorschau** der Quelldatei sowie ein **editierbarer Dateiname-Bereich**
|
||||||
|
verfügbar. Der Lauf verwendet den zuletzt gespeicherten Stand der `.properties`-Datei;
|
||||||
|
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
|
||||||
|
über den Abbrechen-Knopf beendet den Lauf nach Abschluss der gerade bearbeiteten Datei.
|
||||||
|
Während eines laufenden Batches ist Tab 1 gesperrt; ein Hinweis weist darauf hin.
|
||||||
|
- **Tab „Scheduler"** – Optionaler automatischer Scheduler für periodische Verarbeitungsläufe.
|
||||||
|
Kann gestartet, gestoppt und mit einem konfigurierten Intervall betrieben werden. Während
|
||||||
|
der Scheduler aktiv ist, sind Tab 1 „Konfiguration" und der manuelle Lauf gesperrt.
|
||||||
|
Erfordert `scheduler.enabled=true` und ein gültiges `scheduler.interval.seconds` in der
|
||||||
|
gespeicherten Konfiguration.
|
||||||
|
- **Tab „Verlauf"** – Ansicht aller bisher verarbeiteten Dokumente mit Status, Dateinamen
|
||||||
|
und Verarbeitungsdetails direkt aus der SQLite-Datenbank. Ermöglicht Status-Reset und
|
||||||
|
Löschung einzelner Einträge.
|
||||||
|
- **Tab „Prompt"** – Lädt, bearbeitet und speichert die konfigurierte Prompt-Datei direkt
|
||||||
|
aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel).
|
||||||
|
Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`).
|
||||||
|
Ein „Auf Standard zurücksetzen"-Button befüllt die TextArea mit der Standard-Vorlage,
|
||||||
|
ohne zu speichern. Fehlt die Prompt-Datei am konfigurierten Pfad, wird ein
|
||||||
|
„Standard-Prompt erstellen"-Button angezeigt. Der Tab wird beim ersten Öffnen automatisch
|
||||||
|
geladen. Tab-Wechsel mit ungespeicherten Änderungen löst einen Bestätigungsdialog aus.
|
||||||
|
|
||||||
|
Der headless Betrieb über den Windows Task Scheduler bleibt unverändert bestehen und
|
||||||
|
kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz ist genau
|
||||||
|
ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf
|
||||||
|
wird jedoch nicht technisch erkannt oder blockiert.
|
||||||
|
|
||||||
|
### Automatischer Scheduler
|
||||||
|
|
||||||
|
Der GUI-Tab „Scheduler" ermöglicht den Betrieb eines optionalen, periodisch laufenden
|
||||||
|
Schedulers, der automatisch Verarbeitungsläufe anstößt.
|
||||||
|
|
||||||
|
**Konfigurationsparameter:**
|
||||||
|
|
||||||
|
| Parameter | Beschreibung | Standard |
|
||||||
|
|---|---|---|
|
||||||
|
| `scheduler.enabled` | Scheduler im GUI-Modus aktivieren (`true`/`false`); wird im headless Betrieb ignoriert | `false` |
|
||||||
|
| `scheduler.interval.seconds` | Intervall zwischen automatischen Läufen in Sekunden (Integer >= 30; Pflicht wenn `scheduler.enabled=true`); wird im headless Betrieb ignoriert | – |
|
||||||
|
|
||||||
|
Ungültige Werte (kein Integer, < 30 oder leer bei `scheduler.enabled=true`) verhindern den
|
||||||
|
Scheduler-Start und werden im GUI-Tab als Fehler gemeldet.
|
||||||
|
|
||||||
|
**Autostart:** Ist `scheduler.enabled=true` in der gespeicherten Konfiguration, startet der
|
||||||
|
Scheduler automatisch, wenn die Konfiguration beim GUI-Start geladen wird. Der erste
|
||||||
|
Verarbeitungslauf beginnt **unmittelbar** nach dem Scheduler-Start (kein initiales Warten).
|
||||||
|
|
||||||
|
**Headless-Betrieb:** Im headless Betrieb werden `scheduler.enabled` und
|
||||||
|
`scheduler.interval.seconds` vollständig ignoriert. Der Scheduler ist ausschließlich im
|
||||||
|
GUI-Modus verfügbar.
|
||||||
|
|
||||||
|
**Sperrverhalten:** Solange der Scheduler aktiv ist, ist Tab 1 „Konfiguration" gesperrt
|
||||||
|
(Bearbeitungssperre mit Hinweisbanner). Manuelles Starten eines Laufs ist ebenfalls nicht
|
||||||
|
möglich. Nach dem Stoppen des Schedulers werden beide Sperren automatisch aufgehoben.
|
||||||
|
|
||||||
|
**Schließen der Anwendung:** Versucht der Benutzer das Fenster zu schließen, während der
|
||||||
|
Scheduler aktiv ist oder ein Lauf läuft, erscheint ein Informationsdialog. Das Schließen
|
||||||
|
wird blockiert, bis der Scheduler gestoppt und kein Lauf mehr aktiv ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Java 21 (JRE oder JDK)
|
- Zugang zu einem KI-Dienst (API-Schlüssel erforderlich; unterstützte Provider: OpenAI-kompatibel, Anthropic Claude)
|
||||||
- Zugang zu einem OpenAI-kompatiblen KI-Dienst (API-Schlüssel erforderlich)
|
|
||||||
- Quellordner mit OCR-verarbeiteten PDF-Dateien
|
- Quellordner mit OCR-verarbeiteten PDF-Dateien
|
||||||
- Schreibzugriff auf Zielordner und Datenbankverzeichnis
|
- Schreibzugriff auf Zielordner und Datenbankverzeichnis
|
||||||
|
|
||||||
|
### Java-Laufzeitumgebung
|
||||||
|
|
||||||
|
- Bei Verwendung des **Shade-JAR** direkt: **Java 21 JRE** auf dem Zielsystem erforderlich.
|
||||||
|
- Bei Verwendung des **Windows-Installers (V3.0)**: **keine** separate Java-Installation notwendig –
|
||||||
|
die JRE 21 ist in der installierten Anwendung eingebettet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Start des ausführbaren JAR
|
## Start des ausführbaren JAR
|
||||||
@@ -26,16 +152,46 @@ Das ausführbare JAR wird durch den Maven-Build im Verzeichnis
|
|||||||
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Anwendung liest die Konfiguration aus `config/application.properties` relativ zum
|
Ohne weitere Argumente öffnet sich die **GUI** (Standardstart ab V2.0).
|
||||||
|
|
||||||
|
Für den **headless Betrieb** (Batch-/Scheduler-Start):
|
||||||
|
|
||||||
|
```
|
||||||
|
java -jar pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Anwendung liest die Konfiguration standardmäßig aus `config/application.properties` relativ zum
|
||||||
Arbeitsverzeichnis, in dem der Befehl ausgeführt wird.
|
Arbeitsverzeichnis, in dem der Befehl ausgeführt wird.
|
||||||
|
|
||||||
|
### Konsolen-Encoding unter Windows
|
||||||
|
|
||||||
|
Die Anwendung schreibt alle Log-Ausgaben in UTF-8. Windows-Konsolen (PowerShell, CMD) verwenden
|
||||||
|
standardmäßig den OEM-Codepage (z. B. CP850), was zu unlesbaren Sonderzeichen führt.
|
||||||
|
|
||||||
|
**Lösung:** Konsole vor dem Start auf UTF-8 umschalten:
|
||||||
|
|
||||||
|
```
|
||||||
|
chcp 65001
|
||||||
|
java -jar pdf-umbenenner-bootstrap-*.jar --headless
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ kann die UTF-8-Ausgabe auch als JVM-Argument angegeben werden (Java 17+):
|
||||||
|
|
||||||
|
```
|
||||||
|
java -Dstdout.encoding=UTF-8 -jar pdf-umbenenner-bootstrap-*.jar --headless
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Hinweis:** Die mitgelieferten Batch-Dateien (`PDF-KI-Renamer.bat`, `PDF-KI-Renamer-GUI.bat`)
|
||||||
|
> rufen `chcp 65001` automatisch auf. Der Windows Task Scheduler schreibt Log-Ausgaben in eine
|
||||||
|
> Protokolldatei, die stets UTF-8-kodiert ist – dort entsteht kein Anzeigeproblem.
|
||||||
|
|
||||||
### Start über Windows Task Scheduler
|
### Start über Windows Task Scheduler
|
||||||
|
|
||||||
Empfohlene Startsequenz für den Windows Task Scheduler:
|
Empfohlene Startsequenz für den headless Betrieb über den Windows Task Scheduler:
|
||||||
|
|
||||||
1. Aktion: Programm/Skript starten
|
1. Aktion: Programm/Skript starten
|
||||||
2. Programm: `java`
|
2. Programm: `java`
|
||||||
3. Argumente: `-jar C:\Pfad\zur\Installation\pdf-umbenenner-bootstrap\target\pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar`
|
3. Argumente: `-jar C:\Pfad\zur\Installation\pdf-umbenenner-bootstrap\target\pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar --headless`
|
||||||
4. Starten in: `C:\Pfad\zur\Installation` (muss das Verzeichnis mit `config\application.properties` und `config\prompts\` enthalten)
|
4. Starten in: `C:\Pfad\zur\Installation` (muss das Verzeichnis mit `config\application.properties` und `config\prompts\` enthalten)
|
||||||
|
|
||||||
> **Hinweis:** Das „Starten in"-Verzeichnis ist das Arbeitsverzeichnis der Anwendung.
|
> **Hinweis:** Das „Starten in"-Verzeichnis ist das Arbeitsverzeichnis der Anwendung.
|
||||||
@@ -43,11 +199,26 @@ Empfohlene Startsequenz für den Windows Task Scheduler:
|
|||||||
> `config/prompts/` müssen relativ zu diesem Verzeichnis erreichbar sein. Der JAR-Pfad
|
> `config/prompts/` müssen relativ zu diesem Verzeichnis erreichbar sein. Der JAR-Pfad
|
||||||
> in den Argumenten muss absolut oder relativ zum Starten-in-Verzeichnis korrekt angegeben sein.
|
> in den Argumenten muss absolut oder relativ zum Starten-in-Verzeichnis korrekt angegeben sein.
|
||||||
|
|
||||||
|
Alternativ kann über `--config` ein expliziter Konfigurationspfad angegeben werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
java -jar ... --headless --config S:\Betrieb\meine-config.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Wichtig:** Zeigt `--config` auf eine nicht vorhandene Datei, bricht die Anwendung mit Exit-Code `1` ab.
|
||||||
|
> Es findet kein stiller Fallback auf `config/application.properties` statt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Die Konfiguration wird aus `config/application.properties` geladen.
|
Die Konfiguration wird aus `config/application.properties` geladen.
|
||||||
|
|
||||||
|
Ein vollständiges Konfigurationsbeispiel mit allen unterstützten Parametern,
|
||||||
|
realistischen Windows-Pfaden und erklärenden Kommentaren liegt unter:
|
||||||
|
|
||||||
|
- [`docs/examples/application.properties`](examples/application.properties)
|
||||||
|
|
||||||
Vorlagen für lokale und Test-Konfigurationen befinden sich in:
|
Vorlagen für lokale und Test-Konfigurationen befinden sich in:
|
||||||
|
|
||||||
- `config/application-local.example.properties`
|
- `config/application-local.example.properties`
|
||||||
@@ -64,6 +235,7 @@ Vorlagen für lokale und Test-Konfigurationen befinden sich in:
|
|||||||
| `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) |
|
| `max.retries.transient` | Maximale transiente Fehlversuche pro Dokument (ganzzahlig, >= 1) |
|
||||||
| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) |
|
| `max.pages` | Maximale Seitenzahl pro Dokument (ganzzahlig, > 0) |
|
||||||
| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) |
|
| `max.text.characters` | Maximale Zeichenanzahl des Dokumenttexts für KI-Anfragen (ganzzahlig, > 0) |
|
||||||
|
| `max.title.length` | Maximale Länge des Basistitels in Zeichen (ganzzahlig, 10..120, Default 60). Werte unter 10 oder über 120 verhindern den Start. Werte 10–39 und 100–120 erzeugen eine Startwarnung. |
|
||||||
| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) |
|
| `prompt.template.file` | Pfad zur externen Prompt-Datei (muss vorhanden sein) |
|
||||||
|
|
||||||
### Provider-Parameter
|
### Provider-Parameter
|
||||||
@@ -96,6 +268,8 @@ Nur der **aktive** Provider muss vollständig konfiguriert sein. Der inaktive Pr
|
|||||||
| `log.directory` | Log-Verzeichnis | `./logs/` |
|
| `log.directory` | Log-Verzeichnis | `./logs/` |
|
||||||
| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` |
|
| `log.level` | Log-Level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` |
|
||||||
| `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` |
|
| `log.ai.sensitive` | KI-Rohantwort und Reasoning ins Log schreiben (`true`/`false`) | `false` |
|
||||||
|
| `scheduler.enabled` | Scheduler im GUI-Modus aktivieren (`true`/`false`); wird im headless Betrieb ignoriert | `false` |
|
||||||
|
| `scheduler.interval.seconds` | Intervall in Sekunden (Integer >= 30; Pflicht wenn `scheduler.enabled=true`); wird im headless Betrieb ignoriert | – |
|
||||||
|
|
||||||
### API-Schlüssel
|
### API-Schlüssel
|
||||||
|
|
||||||
@@ -155,15 +329,45 @@ Der Dateiname der Prompt-Datei dient als Prompt-Identifikator in der Versuchshis
|
|||||||
(SQLite) und ermöglicht so die Nachvollziehbarkeit, welche Prompt-Version für welchen
|
(SQLite) und ermöglicht so die Nachvollziehbarkeit, welche Prompt-Version für welchen
|
||||||
Verarbeitungsversuch verwendet wurde.
|
Verarbeitungsversuch verwendet wurde.
|
||||||
|
|
||||||
Eine Vorlage befindet sich in `config/prompts/template.txt` und kann direkt verwendet oder
|
Eine angepasste Vorlage befindet sich in `config/prompts/template.txt` und kann direkt
|
||||||
an den jeweiligen KI-Dienst angepasst werden.
|
verwendet oder an den jeweiligen KI-Dienst angepasst werden.
|
||||||
|
|
||||||
|
Fehlt die konfigurierte Prompt-Datei, erzeugt die GUI beim Ausführen der technischen Tests
|
||||||
|
automatisch eine deutsche Standardvorlage am konfigurierten Pfad. Ein Beispiel dieser
|
||||||
|
Standardvorlage liegt unter [`docs/examples/prompt.txt`](examples/prompt.txt).
|
||||||
|
|
||||||
Die Anwendung ergänzt den Prompt automatisch um:
|
Die Anwendung ergänzt den Prompt automatisch um:
|
||||||
- einen Dokumenttext-Abschnitt
|
- einen Dokumenttext-Abschnitt
|
||||||
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
|
- eine explizite JSON-Antwortspezifikation mit den Feldern `title`, `reasoning` und `date`
|
||||||
|
|
||||||
Der Prompt in `template.txt` muss deshalb **keine** JSON-Formatanweisung enthalten –
|
### Prompt-Pfad-Auflösung je Betriebsart
|
||||||
nur den inhaltlichen Auftrag an die KI.
|
|
||||||
|
Der Wert von `prompt.template.file` wird **relativ zum Arbeitsverzeichnis** aufgelöst,
|
||||||
|
wenn kein absoluter Pfad angegeben ist. Das Arbeitsverzeichnis hängt von der Betriebsart ab:
|
||||||
|
|
||||||
|
| Betriebsart | Arbeitsverzeichnis | Empfohlener Wert |
|
||||||
|
|---|---|---|
|
||||||
|
| **IDE** | Projekt-Wurzelverzeichnis (in der Regel das Parent-POM-Verzeichnis) | `config/prompts/template.txt` |
|
||||||
|
| **Shade-JAR direkt** | Verzeichnis, aus dem `java -jar ...` aufgerufen wird | `config/prompts/template.txt` |
|
||||||
|
| **Windows Task Scheduler** | „Starten in"-Feld der Task-Konfiguration | absoluter Pfad empfohlen, z. B. `C:\Betrieb\config\prompts\template.txt` |
|
||||||
|
| **Windows-Installer (MSI)** | Installationsverzeichnis | absoluter Pfad empfohlen |
|
||||||
|
|
||||||
|
> **Empfehlung für den Windows-Produktivbetrieb:** Verwenden Sie einen **absoluten Pfad**
|
||||||
|
> für `prompt.template.file`. Damit ist die Prompt-Datei unabhängig vom Arbeitsverzeichnis
|
||||||
|
> immer eindeutig auffindbar – insbesondere beim Start über den Windows Task Scheduler,
|
||||||
|
> wo das Arbeitsverzeichnis je nach Konfiguration variieren kann.
|
||||||
|
|
||||||
|
### Bearbeitung über den GUI-Prompt-Tab
|
||||||
|
|
||||||
|
Im GUI-Tab „Prompt" kann die Prompt-Datei ohne externen Editor gelesen, bearbeitet und
|
||||||
|
gespeichert werden. Das Speichern erfolgt atomar; ein Rollback schlägt nur fehl, wenn
|
||||||
|
das Dateisystem kein atomisches Verschieben im selben Verzeichnis unterstützt (in diesem
|
||||||
|
Fall wird kein stiller Fallback durchgeführt).
|
||||||
|
|
||||||
|
Der Tab zeigt stets die Datei an, die beim GUI-Start als `prompt.template.file` konfiguriert
|
||||||
|
war. Wird während der GUI-Session eine andere `.properties`-Datei geöffnet (Tab „Konfiguration"),
|
||||||
|
aktualisiert sich der Prompt-Tab nicht automatisch – in diesem Fall sollte die GUI neu gestartet
|
||||||
|
oder der Prompt-Tab durch erneutes Auswählen manuell neu geladen werden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -182,7 +386,7 @@ YYYY-MM-DD - Titel(1).pdf
|
|||||||
YYYY-MM-DD - Titel(2).pdf
|
YYYY-MM-DD - Titel(2).pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Suffix zählt nicht zu den 20 Zeichen des Basistitels.
|
Das Suffix zählt nicht zur konfigurierten maximalen Titellänge des Basistitels.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -262,7 +466,27 @@ Die Anwendung verwendet eine exklusive Lock-Datei, um parallele Instanzen zu ver
|
|||||||
Wenn bereits eine Instanz läuft, beendet sich die neue Instanz sofort mit Exit-Code `1`.
|
Wenn bereits eine Instanz läuft, beendet sich die neue Instanz sofort mit Exit-Code `1`.
|
||||||
|
|
||||||
Der Pfad der Lock-Datei ist über `runtime.lock.file` konfigurierbar.
|
Der Pfad der Lock-Datei ist über `runtime.lock.file` konfigurierbar.
|
||||||
Ohne Konfiguration wird `pdf-umbenenner.lock` im Arbeitsverzeichnis verwendet.
|
|
||||||
|
### Pfadauflösung der Lock-Datei
|
||||||
|
|
||||||
|
| Pfadtyp | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| **Absoluter Pfad** | Wird direkt verwendet. Schlägt das Anlegen der Lock-Datei fehl, bricht der Start mit einer klaren Fehlermeldung ab – kein Fallback. |
|
||||||
|
| **Relativer oder unkonfigurierter Pfad** | Zweistufige Auflösung: (1) relativ zum Verzeichnis der JAR-Datei (`CodeSource.getLocation()`), (2) Fallback auf das Benutzerverzeichnis (`user.home`). Erst wenn auch `user.home` fehlschlägt, bricht der Start ab. |
|
||||||
|
|
||||||
|
Fehlende übergeordnete Verzeichnisse werden automatisch angelegt.
|
||||||
|
|
||||||
|
Der tatsächlich verwendete absolute Pfad der Lock-Datei wird beim Start auf INFO-Level geloggt, z. B.:
|
||||||
|
|
||||||
|
```
|
||||||
|
Lock-Datei: C:\Users\Funny\Documents\pdf-umbenenner.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Auflösungslogik gilt sowohl für den GUI- als auch für den headless Start.
|
||||||
|
|
||||||
|
> **Empfehlung für den MSI-Betrieb:** Da das Installationsverzeichnis `C:\Program Files\`
|
||||||
|
> schreibgeschützt ist, muss `runtime.lock.file` als absoluter Pfad auf ein beschreibbares
|
||||||
|
> Verzeichnis zeigen (z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,18 +496,325 @@ Die SQLite-Datei enthält:
|
|||||||
|
|
||||||
- **Dokument-Stammsätze**: Gesamtstatus, Fehlerzähler, letzter Zieldateiname, Zeitstempel
|
- **Dokument-Stammsätze**: Gesamtstatus, Fehlerzähler, letzter Zieldateiname, Zeitstempel
|
||||||
- **Versuchshistorie**: Jeder Verarbeitungsversuch mit Modell, Prompt-Identifikator,
|
- **Versuchshistorie**: Jeder Verarbeitungsversuch mit Modell, Prompt-Identifikator,
|
||||||
KI-Rohantwort, Reasoning, Datum, Titel und Fehlerstatus
|
KI-Rohantwort, Reasoning, Datum, Titel, Fehlerstatus und Fehlerdetails
|
||||||
|
|
||||||
Die Datenbank ist die führende Wahrheitsquelle für Bearbeitungsstatus und Nachvollziehbarkeit.
|
Die Datenbank ist die führende Wahrheitsquelle für Bearbeitungsstatus und Nachvollziehbarkeit.
|
||||||
Sie muss nicht manuell verwaltet werden – das Schema wird beim Start automatisch initialisiert.
|
Sie muss nicht manuell verwaltet werden – das Schema wird beim Start automatisch initialisiert.
|
||||||
|
|
||||||
|
### Fehlerursache im Verlauf-Tab
|
||||||
|
|
||||||
|
Verarbeitungsversuche mit Status `FAILED_FINAL`, `FAILED_RETRYABLE` oder
|
||||||
|
`SKIPPED_FINAL_FAILURE` speichern eine nutzerverständliche Fehlerursache
|
||||||
|
(`failure_details`). Diese wird im Verlauf-Tab im Detailbereich des jeweiligen
|
||||||
|
Dokuments angezeigt. Ältere Einträge ohne Fehlerdetails zeigen einen Platzhaltertext.
|
||||||
|
Fehlerdetails werden auf 1000 Zeichen begrenzt und enthalten keine rohen
|
||||||
|
Provider-Meldungen oder API-Schlüssel.
|
||||||
|
|
||||||
|
### Neue Datenbank anlegen
|
||||||
|
|
||||||
|
Über den Menüpunkt **Datenbank → Neue Datenbank anlegen...** kann aus der GUI
|
||||||
|
heraus eine neue, leere SQLite-Datenbank erstellt und sofort aktiviert werden,
|
||||||
|
ohne die Anwendung neu zu starten.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
|
||||||
|
1. Dateidialog öffnet (Filter: `*.sqlite` und `*.db`); Zieldatei wählen oder eingeben.
|
||||||
|
2. Sicherheitsprüfung: aktive und gewählte Datei werden normalisiert verglichen
|
||||||
|
(case-insensitive unter Windows). Bei Übereinstimmung erscheint eine Fehlermeldung.
|
||||||
|
3. Bei bereits existierender Fremddatei: Bestätigungsdialog „Die Datei existiert bereits.
|
||||||
|
Überschreiben?"
|
||||||
|
4. Neue SQLite-Datei wird als temporäre Datei erzeugt, Flyway führt alle Migrationsskripte
|
||||||
|
auf neuesten Stand aus, dann Verbindungstest.
|
||||||
|
5. Nach erfolgreichem Test: atomarer Move zur Zieldatei.
|
||||||
|
6. Aktive Datenbankverbindung der Anwendung wechselt zur neuen DB.
|
||||||
|
7. Der Verlauf-Tab lädt neu und zeigt „Noch keine Verarbeitungen vorhanden."
|
||||||
|
8. Die Statuszeile aktualisiert den DB-Pfad.
|
||||||
|
|
||||||
|
> **Wichtig:** Die Konfigurationsdatei wird durch den Wechsel automatisch als geändert
|
||||||
|
> markiert. **Konfiguration speichern**, damit die neue Datenbank beim nächsten Start
|
||||||
|
> der Anwendung verwendet wird.
|
||||||
|
|
||||||
|
**Fehlerfall:** Schlägt ein Schritt fehl, bleibt die bisherige Datenbank unverändert
|
||||||
|
in Betrieb. Die temporäre Datei wird gelöscht. Ein Fehlerdialog erscheint.
|
||||||
|
|
||||||
|
Der Menüpunkt ist nur aktiv, wenn kein Verarbeitungslauf läuft.
|
||||||
|
Der headless Betrieb ist von dieser Funktion nicht betroffen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build und Packaging
|
||||||
|
|
||||||
|
### Gemeinsames ausführbares JAR
|
||||||
|
|
||||||
|
Die gesamte Anwendung wird als **ein einziges ausführbares JAR** ausgeliefert, das GUI-Start
|
||||||
|
und headless Batch-Betrieb vereint. Eine separate JavaFX-Installation ist nicht erforderlich.
|
||||||
|
|
||||||
|
Das JAR wird vom Maven-Shade-Plugin im Modul `pdf-umbenenner-bootstrap` erzeugt.
|
||||||
|
Nach einem erfolgreichen Build liegt es unter:
|
||||||
|
|
||||||
|
```
|
||||||
|
pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieses JAR enthält alle Abhängigkeiten inklusive der JavaFX-Plattformbibliotheken
|
||||||
|
für Windows (Classifier `win`). Die nativen JavaFX-DLLs werden beim GUI-Start
|
||||||
|
von JavaFX selbst in ein temporäres Verzeichnis extrahiert.
|
||||||
|
|
||||||
|
### Integrierte JavaFX-Laufzeit
|
||||||
|
|
||||||
|
JavaFX ist als Maven-Dependency im Modul `pdf-umbenenner-adapter-in-gui` mit
|
||||||
|
Windows-Classifier deklariert (`javafx-base:win`, `javafx-graphics:win`,
|
||||||
|
`javafx-controls:win`). Das Shade-JAR schließt diese Bibliotheken ein, sodass
|
||||||
|
der GUI-Start ohne separate JavaFX-Installation auf dem Zielsystem funktioniert.
|
||||||
|
|
||||||
|
Nur das Modul `pdf-umbenenner-adapter-in-gui` hängt direkt von JavaFX ab.
|
||||||
|
Die Module `domain`, `application`, `adapter-in-cli` und `adapter-out` sind
|
||||||
|
vollständig JavaFX-frei.
|
||||||
|
|
||||||
|
### Headless-Start ohne JavaFX-Initialisierung
|
||||||
|
|
||||||
|
Beim headless Start (`--headless`) wird JavaFX **nicht** initialisiert. Der
|
||||||
|
`GuiAdapter` wird nur dann instanziiert und gestartet, wenn der Startmodus GUI ist.
|
||||||
|
JavaFX-Klassen sind zwar im Shade-JAR enthalten, werden im headless Pfad jedoch
|
||||||
|
nicht geladen. Headless läuft damit auch auf Windows Server-Systemen ohne
|
||||||
|
JavaFX-fähige Grafiklaufzeit.
|
||||||
|
|
||||||
|
### Windows-Installer (V3.0)
|
||||||
|
|
||||||
|
Ab V3.0 steht neben dem Shade-JAR ein vollwertiger **MSI-Installer** für Windows 10/11 (x64)
|
||||||
|
und Windows Server 2022 (x64) bereit. Der Installer enthält eine eingebettete JRE 21 und
|
||||||
|
benötigt keine separate Java-Installation auf dem Zielsystem. Das Shade-JAR bleibt das
|
||||||
|
primäre Distributionsartefakt; der MSI ist eine zusätzliche Option für Systeme ohne
|
||||||
|
Java-Installation und für den Standard-Installationspfad nach `C:\Program Files\`.
|
||||||
|
|
||||||
|
> **Hinweis zur CI-Umgebung:** Der MSI-Build ist Windows-only (`jpackage` + WiX Toolset 3.x).
|
||||||
|
> Jenkins läuft im Linux-Container auf dem Synology NAS und kann kein MSI erzeugen.
|
||||||
|
> Der MSI-Build wird bewusst manuell auf der Windows-Entwicklungsmaschine ausgeführt.
|
||||||
|
|
||||||
|
**Voraussetzungen für den Installer-Build (nur auf der Entwicklungsmaschine):**
|
||||||
|
- Windows x64
|
||||||
|
- JDK 21 im PATH
|
||||||
|
- [WiX Toolset 3.x](https://wixtoolset.org/) im PATH
|
||||||
|
|
||||||
|
**MSI bauen:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Der normale Build (`mvn clean verify`) ist vom Profil `release` vollständig unberührt
|
||||||
|
und benötigt **kein** WiX Toolset.
|
||||||
|
|
||||||
|
Das Ergebnis liegt unter:
|
||||||
|
|
||||||
|
```
|
||||||
|
pdf-umbenenner-packaging/target/dist/
|
||||||
|
PDF-KI-Renamer-2.5.0.msi ← Windows-Installer
|
||||||
|
PDF-KI-Renamer.bat ← Headless-Start (zusätzlich kopiert)
|
||||||
|
PDF-KI-Renamer-GUI.bat ← GUI-Start (zusätzlich kopiert)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Installationsverzeichnis:**
|
||||||
|
|
||||||
|
Der Installer legt die Anwendung nach `C:\Program Files\PDF KI Renamer\` ab.
|
||||||
|
Beide Batch-Dateien landen ebenfalls dort. Der Installer erstellt:
|
||||||
|
- einen Startmenü-Eintrag in der Gruppe `PDF KI Renamer` (startet die GUI)
|
||||||
|
- einen Desktop-Shortcut (startet die GUI)
|
||||||
|
|
||||||
|
Die Deinstallation erfolgt über „Programme und Features" in der Windows-Systemsteuerung.
|
||||||
|
Vom Installer angelegte Dateien werden entfernt; Nutzerdaten unter `C:\ProgramData\PDF KI Renamer\`
|
||||||
|
(Konfiguration, Logs, SQLite-Datenbank) bleiben erhalten.
|
||||||
|
|
||||||
|
**Konfigurationsverzeichnis (`ProgramData`):**
|
||||||
|
|
||||||
|
Das empfohlene Konfigurationsverzeichnis für den produktiven Betrieb ist:
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\ProgramData\PDF KI Renamer\config\
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Anwendung löst dieses Verzeichnis **nicht** automatisch auf. Der Pfad zur
|
||||||
|
Konfigurationsdatei muss weiterhin explizit über `--config` angegeben werden
|
||||||
|
(siehe „CLI-Optionen"). Der Installer legt eine Beispiel-Konfiguration namens
|
||||||
|
`application.example.properties` neben den installierten Artefakten im
|
||||||
|
Installationsverzeichnis ab. **Der Betreiber muss diese Beispieldatei manuell nach**
|
||||||
|
`C:\ProgramData\PDF KI Renamer\config\` **kopieren und anpassen.**
|
||||||
|
|
||||||
|
**Beispielaufruf headless mit installierter Anwendung:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
"C:\Program Files\PDF KI Renamer\PDF-KI-Renamer.bat" --config "C:\ProgramData\PDF KI Renamer\config\application.properties"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Der MSI ist nicht signiert. Beim Installieren erscheint eine
|
||||||
|
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
|
||||||
|
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
|
||||||
|
|
||||||
|
**Empfehlung für Pfade im MSI-Betrieb:**
|
||||||
|
|
||||||
|
Für den MSI-Betrieb (Startmenü, Task Scheduler) müssen alle Dateipfade als **absolute Pfade**
|
||||||
|
konfiguriert werden. Relative Pfade werden relativ zum Installationsverzeichnis
|
||||||
|
`C:\Program Files\PDF KI Renamer\` aufgelöst, das **schreibgeschützt** ist. Dadurch
|
||||||
|
schlagen Schreibversuche (Logs, SQLite-Datenbank, Lock-Datei) ohne Fehlermeldung fehl.
|
||||||
|
|
||||||
|
> **Warnung – Relative Pfade im MSI-Betrieb nicht verwenden:**
|
||||||
|
> Pfade wie `./logs`, `./work/local/logs` oder `logs/` werden im MSI-Betrieb relativ
|
||||||
|
> zum Installationsverzeichnis aufgelöst. Das Installationsverzeichnis ist für normale
|
||||||
|
> Benutzerkonten schreibgeschützt. Log4j2 scheitert dann still, ohne eine sichtbare
|
||||||
|
> Fehlermeldung zu erzeugen.
|
||||||
|
|
||||||
|
> **Warnung – Backslashes in `.properties`-Dateien:**
|
||||||
|
> In Java-`.properties`-Dateien werden Backslashes (`\`) als Escape-Zeichen interpretiert.
|
||||||
|
> Windows-Pfade wie `C:\Users\Funny\Logs` müssen entweder mit Forward-Slashes
|
||||||
|
> (`C:/Users/Funny/Logs`) oder mit doppelten Backslashes (`C:\\Users\\Funny\\Logs`)
|
||||||
|
> angegeben werden. Einfache Backslashes werden stillschweigend falsch interpretiert.
|
||||||
|
|
||||||
|
Betroffene Parameter:
|
||||||
|
|
||||||
|
| Parameter | Empfehlung |
|
||||||
|
|---|---|
|
||||||
|
| `log.directory` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/logs` |
|
||||||
|
| `runtime.lock.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/pdf-umbenenner.lock` |
|
||||||
|
| `prompt.template.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/prompts/template.txt` |
|
||||||
|
| `sqlite.file` | Absoluter Pfad, z. B. `C:/ProgramData/PDF KI Renamer/config/pdf-umbenenner.db` |
|
||||||
|
|
||||||
|
Das empfohlene Konfigurationsverzeichnis für alle schreibbaren Daten im MSI-Betrieb ist
|
||||||
|
`C:\ProgramData\PDF KI Renamer\`, da dieses Verzeichnis standardmäßig für alle
|
||||||
|
Benutzerkonten schreibbar ist und bei der Deinstallation erhalten bleibt.
|
||||||
|
|
||||||
|
**Diagnose: Log-Datei-Prüfpunkt in den technischen Tests**
|
||||||
|
|
||||||
|
Die technischen Tests (Schaltfläche „Technische Tests ausführen" im Konfigurationseditor)
|
||||||
|
enthalten einen dedizierten Prüfpunkt **„Log-Verzeichnis beschreibbar"**, der anzeigt:
|
||||||
|
|
||||||
|
- den konfigurierten `log.directory`-Wert (roh und als aufgelöster absoluter Pfad),
|
||||||
|
- ob das Verzeichnis vorhanden und beschreibbar ist,
|
||||||
|
- den tatsächlichen Log-Dateipfad aus der laufenden Log4j2-Konfiguration.
|
||||||
|
|
||||||
|
Ein nicht beschreibbares Log-Verzeichnis wird als **Warnung** angezeigt, nicht als Fehler
|
||||||
|
(die Anwendung kann ohne Datei-Logging laufen). Der Prüfpunkt hilft, den typischen
|
||||||
|
MSI-Betriebsfehler – relatives `log.directory` auf schreibgeschütztem Installationspfad –
|
||||||
|
frühzeitig zu erkennen.
|
||||||
|
|
||||||
|
### MSI-Release-Checkliste
|
||||||
|
|
||||||
|
Die folgende Checkliste ist vor jeder MSI-Auslieferung manuell abzuarbeiten.
|
||||||
|
|
||||||
|
- [ ] Neuinstallation auf sauberer Windows-Umgebung ohne vorinstalliertes Java
|
||||||
|
- [ ] Installation in Installationspfad **mit Leerzeichen** (z. B. `C:\Program Files\PDF KI Renamer\`)
|
||||||
|
- [ ] Upgrade von installiertem Vorgänger-MSI (kein manuelles Deinstallieren)
|
||||||
|
- [ ] GUI-Start über Startmenü-Eintrag
|
||||||
|
- [ ] Headless-Start über `PDF-KI-Renamer.bat` im Windows Task Scheduler
|
||||||
|
- [ ] Desktop-Shortcut vorhanden oder Einschränkung hier dokumentiert
|
||||||
|
- [ ] App-Version `3.0.x` im Windows-Installer sichtbar („Programme und Features")
|
||||||
|
- [ ] Deinstallation sauber – Konfiguration unter `C:\ProgramData\PDF KI Renamer\` bleibt erhalten
|
||||||
|
- [ ] SmartScreen-Warnung erscheint und wird durch „Weitere Informationen → Trotzdem ausführen" bestätigt
|
||||||
|
- [ ] BAT-Dateien funktionieren bei Installationspfad mit Leerzeichen
|
||||||
|
- [ ] Anwendungsstart **ohne Entwicklungs-JDK** erfolgreich: GUI-Start, PDF laden und rendern, Verarbeitungslauf starten, Verlaufs-Tab öffnen (Verifikation der `addModules`-Liste)
|
||||||
|
|
||||||
|
> **Hinweis zur JDK-freien Laufzeit-Verifikation:** Nur ein erfolgreicher Test
|
||||||
|
> auf einem System ohne installiertes JDK bestätigt die Vollständigkeit der
|
||||||
|
> `addModules`-Liste in `pdf-umbenenner-packaging/pom.xml`. Die aktuelle Liste
|
||||||
|
> wurde per `jdeps --print-module-deps --ignore-missing-deps` ermittelt;
|
||||||
|
> vollständige Ausgabe in `pdf-umbenenner-packaging/jdeps-output.txt`.
|
||||||
|
|
||||||
|
### Build-Kommandos
|
||||||
|
|
||||||
|
**Vollständiger Reactor-Build** (alle Module, Tests, Packaging):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\mvnw.cmd clean verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf Unix-Systemen (headless CI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw clean verify
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nur das ausführbare JAR erzeugen** (überspringt Tests):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\mvnw.cmd clean package -pl pdf-umbenenner-bootstrap --also-make -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Selektiver Reactor-Build** (ohne Coverage-Modul, z. B. während der Entwicklung):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make
|
||||||
|
```
|
||||||
|
|
||||||
|
### Technische Hinweise zum Shade-JAR
|
||||||
|
|
||||||
|
- Signaturdateien (`*.SF`, `*.DSA`, `*.RSA`) signierter JARs (u. a. JavaFX) werden
|
||||||
|
beim Shading entfernt, da sie im zusammengeführten JAR ungültig wären.
|
||||||
|
- JPMS-Moduldeskriptoren (`module-info.class`) werden entfernt, da JavaFX als
|
||||||
|
modulares Framework mit dem nicht-modularen Fat-JAR-Modell kollidieren würde.
|
||||||
|
- `META-INF/services`-Einträge aus allen Abhängigkeiten werden durch den
|
||||||
|
`ServicesResourceTransformer` zusammengeführt statt überschrieben.
|
||||||
|
- Der Main-Class-Eintrag im Manifest verweist auf
|
||||||
|
`de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication`.
|
||||||
|
Diese Klasse erweitert bewusst **nicht** `javafx.application.Application`,
|
||||||
|
um den JavaFX-Modul-System-Launcher-Check zu umgehen, der Fat-JAR-Ausführung
|
||||||
|
blockieren würde. Der GUI-Pfad ruft `Application.launch(...)` explizit auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GUI: Selektive Wiederverarbeitung und Status-Reset
|
||||||
|
|
||||||
|
Die GUI ermöglicht nach Abschluss eines Verarbeitungslaufs zwei zusätzliche Aktionen auf der Ergebnisliste:
|
||||||
|
|
||||||
|
### Selektion in der Ergebnisliste
|
||||||
|
|
||||||
|
Die Ergebnisliste enthält eine **Checkbox pro Zeile** sowie eine **Master-Checkbox** zum Auswählen aller Einträge.
|
||||||
|
- Auswahl erfolgt wie im Windows Explorer mit **Shift/Strg-Mehrfachselektion**
|
||||||
|
- Alle vier Statustypen sind selektierbar: erfolgreich, retryable, permanent fehlgeschlagen, übersprungen
|
||||||
|
- Während eines Laufs ist die Selektion **gesperrt**
|
||||||
|
|
||||||
|
### Button „Erneut verarbeiten"
|
||||||
|
|
||||||
|
**Aktion:** DB-Status zurücksetzen + sofortiger Mini-Lauf nur für ausgewählte Dateien.
|
||||||
|
|
||||||
|
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||||
|
- Der Mini-Lauf arbeitet auf einem Snapshot der beim Klick ausgewählten Einträge
|
||||||
|
- Nicht ausgewählte Einträge bleiben unverändert in der Liste
|
||||||
|
- Verhalten identisch zu regulärem Lauf (gleiche Anwendungslogik, nur eingeschränkte Dateimenge)
|
||||||
|
|
||||||
|
**Besonderheit bei identischem Zieldateinamen:** Verarbeitet der KI-Provider wieder denselben Dateinamen wie ein vorangegangener erfolgreicher Lauf, erhält der Eintrag **Status erfolgreich** – es wird keine erneute Kopie erzeugt, kein Fehler.
|
||||||
|
|
||||||
|
**Fehlende Quelldatei:** Ist die Datei zum Zeitpunkt des Mini-Laufs nicht mehr vorhanden, erhält der Eintrag **Status permanent fehlgeschlagen** mit Meldung „Quelldatei nicht gefunden".
|
||||||
|
|
||||||
|
### Button „Status zurücksetzen"
|
||||||
|
|
||||||
|
**Aktion:** Nur DB-Status zurücksetzen, keine sofortige Verarbeitung.
|
||||||
|
|
||||||
|
- Aktiv nur wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||||
|
- Betroffene Zeilen erhalten die Kennzeichnung **„Zurückgesetzt – wartet auf nächsten Lauf"**
|
||||||
|
- Beim nächsten regulären Lauf werden zurückgesetzte Dateien automatisch mitgenommen
|
||||||
|
- **Best-effort-Reset:** Erfolgreiche und fehlgeschlagene Resets werden pro Eintrag einzeln durchgeführt; Zusammenfassung zeigt Erfolge und Fehler
|
||||||
|
|
||||||
|
### Verhalten während eines Mini-Laufs
|
||||||
|
|
||||||
|
- Der **Abbrechen-Button** gilt auch für Mini-Läufe (Soft-Stop)
|
||||||
|
- **Tab 1 „Konfiguration" ist während des Mini-Laufs gesperrt**
|
||||||
|
- Nach Soft-Stop: bereits verarbeitete Einträge behalten neuen Status, noch nicht gestartete zurückgesetzte Einträge warten auf nächsten regulären Lauf
|
||||||
|
- Fortschrittsbalken zeigt Fortschritt für die ausgewählte Dateimenge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weitere Dokumentation
|
||||||
|
|
||||||
|
Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md) beschrieben.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Systemgrenzen
|
## Systemgrenzen
|
||||||
|
|
||||||
- Nur OCR-verarbeitete, durchsuchbare PDF-Dateien werden verarbeitet
|
- Nur OCR-verarbeitete, durchsuchbare PDF-Dateien werden verarbeitet
|
||||||
- Keine eingebaute OCR-Funktion
|
- Keine eingebaute OCR-Funktion
|
||||||
- Kein Web-UI, keine REST-API, keine interaktive Bedienung
|
- Kein Web-UI, keine REST-API
|
||||||
- Kein interner Scheduler – der Start erfolgt extern (z. B. Windows Task Scheduler)
|
- Die GUI ermöglicht Konfiguration, Validierung, technische Diagnose und die Ausführung von Verarbeitungsläufen mit integrierter PDF-Vorschau und editierbarem Dateiname
|
||||||
|
- Kein interner Scheduler im headless Betrieb – der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`); im GUI-Modus steht optional ein interner Scheduler zur Verfügung (Tab „Scheduler")
|
||||||
- Quelldateien werden nie überschrieben, verschoben oder gelöscht
|
- Quelldateien werden nie überschrieben, verschoben oder gelöscht
|
||||||
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
|
- Die Identifikation erfolgt über SHA-256-Fingerprint des Dateiinhalts, nicht über Dateinamen
|
||||||
|
- Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# PDF Umbenenner – vollstaendiges Konfigurationsbeispiel (V2.0)
|
||||||
|
#
|
||||||
|
# Diese Datei zeigt alle unterstuetzten Konfigurationsparameter mit realistischen
|
||||||
|
# Windows-Pfaden und erklaerenden Kommentaren.
|
||||||
|
#
|
||||||
|
# Fuer den produktiven Einsatz: Datei nach config/application.properties kopieren
|
||||||
|
# und Werte anpassen. Der headless Batch-Betrieb liest standardmaessig
|
||||||
|
# config/application.properties relativ zum Arbeitsverzeichnis.
|
||||||
|
#
|
||||||
|
# Die GUI schlaegt beim "Speichern unter" denselben Pfad vor.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pfade
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Quellordner: Ordner, aus dem OCR-verarbeitete PDF-Dateien gelesen werden.
|
||||||
|
# Der Ordner muss vorhanden und lesbar sein.
|
||||||
|
# Beispiel: gemapptes Netzlaufwerk (wird ausdruecklich unterstuetzt)
|
||||||
|
source.folder=S:\\Eingang
|
||||||
|
|
||||||
|
# Zielordner: Ordner, in den die umbenannten Kopien abgelegt werden.
|
||||||
|
# Wird automatisch angelegt, wenn er noch nicht existiert (Schreibzugriff erforderlich).
|
||||||
|
target.folder=S:\\Archiv
|
||||||
|
|
||||||
|
# SQLite-Datenbankdatei fuer Bearbeitungsstatus und Versuchshistorie.
|
||||||
|
# Das uebergeordnete Verzeichnis muss vorhanden sein.
|
||||||
|
sqlite.file=S:\\Archiv\\pdf-umbenenner.db
|
||||||
|
|
||||||
|
# Pfad zur externen Prompt-Datei. Der Dateiname dient als Prompt-Identifikator
|
||||||
|
# in der Versuchshistorie und ermoeg licht die Nachvollziehbarkeit der verwendeten
|
||||||
|
# Prompt-Version. Fehlt die Datei, kann die GUI sie automatisch anlegen (deutsche
|
||||||
|
# Standardvorlage). Ein Beispiel der Standardvorlage liegt unter docs/examples/prompt.txt.
|
||||||
|
prompt.template.file=S:\\Archiv\\prompt.txt
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Aktiver KI-Provider
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Genau ein Provider ist aktiv. Kein automatischer Fallback, keine parallele Nutzung.
|
||||||
|
# Erlaubte Werte: claude, openai-compatible
|
||||||
|
#
|
||||||
|
# Hinweis: Die GUI-Standardvorlage ("Neu") setzt standardmaessig "claude" als aktiven
|
||||||
|
# Provider, weil Claude alphabetisch der erste unterstuetzte Provider ist.
|
||||||
|
ai.provider.active=claude
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider: Anthropic Claude
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wird verwendet, wenn ai.provider.active=claude gesetzt ist.
|
||||||
|
|
||||||
|
# Basis-URL des Anthropic-Dienstes (Standard: https://api.anthropic.com)
|
||||||
|
ai.provider.claude.baseUrl=https://api.anthropic.com
|
||||||
|
|
||||||
|
# Modellname (z. B. claude-3-5-sonnet-20241022)
|
||||||
|
ai.provider.claude.model=claude-3-5-sonnet-20241022
|
||||||
|
|
||||||
|
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
|
||||||
|
ai.provider.claude.timeoutSeconds=60
|
||||||
|
|
||||||
|
# API-Schluessel fuer Anthropic.
|
||||||
|
# Vorrangreihenfolge: Umgebungsvariable ANTHROPIC_API_KEY > dieser Wert.
|
||||||
|
# Das Feld darf leer bleiben, wenn die Umgebungsvariable gesetzt ist.
|
||||||
|
ai.provider.claude.apiKey=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider: OpenAI-kompatibel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Wird verwendet, wenn ai.provider.active=openai-compatible gesetzt ist.
|
||||||
|
# Geeignet fuer OpenAI selbst und jeden API-kompatiblen Drittanbieter.
|
||||||
|
|
||||||
|
# Basis-URL des KI-Dienstes (ohne Pfadsuffix wie /chat/completions).
|
||||||
|
ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1
|
||||||
|
|
||||||
|
# Modellname (z. B. gpt-4o-mini)
|
||||||
|
ai.provider.openai-compatible.model=gpt-4o-mini
|
||||||
|
|
||||||
|
# HTTP-Timeout fuer KI-Anfragen in Sekunden (muss > 0 sein).
|
||||||
|
ai.provider.openai-compatible.timeoutSeconds=30
|
||||||
|
|
||||||
|
# API-Schluessel fuer OpenAI-kompatible Dienste.
|
||||||
|
# Vorrangreihenfolge: OPENAI_COMPATIBLE_API_KEY (Umgebungsvariable) >
|
||||||
|
# PDF_UMBENENNER_API_KEY (veraltete Umgebungsvariable, weiterhin akzeptiert) >
|
||||||
|
# ai.provider.openai-compatible.apiKey (dieser Wert)
|
||||||
|
# Das Feld darf leer bleiben, wenn die Umgebungsvariable gesetzt ist.
|
||||||
|
ai.provider.openai-compatible.apiKey=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verarbeitungslimits
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Maximale Anzahl historisierter transienter Fehlversuche pro Dokument.
|
||||||
|
# Muss eine ganze Zahl >= 1 sein. Wert 0 ist ungueltige Konfiguration.
|
||||||
|
max.retries.transient=3
|
||||||
|
|
||||||
|
# Maximale Seitenzahl pro Dokument. Dokumente mit mehr Seiten werden als
|
||||||
|
# deterministischer Inhaltsfehler behandelt (kein KI-Aufruf).
|
||||||
|
max.pages=10
|
||||||
|
|
||||||
|
# Maximale Zeichenanzahl des Dokumenttexts, der an die KI gesendet wird.
|
||||||
|
# Werte bis 1000: unkritisch.
|
||||||
|
# Werte 1001-3000: erhoehte KI-Kosten moeglich (Warnung in der GUI).
|
||||||
|
# Werte ab 3001: deutlich erhoehte KI-Kosten moeglich (starke Warnung in der GUI).
|
||||||
|
# Standardvorlage der GUI: 1000.
|
||||||
|
max.text.characters=1000
|
||||||
|
|
||||||
|
# Maximale Länge des Basistitels in Zeichen (10..120). Default 60.
|
||||||
|
# Werte unter 10 oder ueber 120 verhindern den Start.
|
||||||
|
# Werte 10-19: Warnung (fuer die meisten Dokumente nicht empfohlen).
|
||||||
|
# Werte 100-120: Warnung (Dateiname wird sehr lang, Kompatibilitaet mit verschluesselten Volumes pruefen).
|
||||||
|
max.title.length=60
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Optionale Parameter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Lock-Datei fuer den Startschutz (verhindert parallele Instanzen).
|
||||||
|
# Ohne Konfiguration: pdf-umbenenner.lock im Arbeitsverzeichnis.
|
||||||
|
runtime.lock.file=S:\\Archiv\\pdf-umbenenner.lock
|
||||||
|
|
||||||
|
# Log-Verzeichnis. Ohne Konfiguration: ./logs/ im Arbeitsverzeichnis.
|
||||||
|
log.directory=S:\\Archiv\\logs
|
||||||
|
|
||||||
|
# Log-Level (DEBUG, INFO, WARN, ERROR). Standard: INFO.
|
||||||
|
log.level=INFO
|
||||||
|
|
||||||
|
# Sensible KI-Inhalte (vollstaendige Rohantwort und Reasoning) ins Log schreiben.
|
||||||
|
# Erlaubte Werte: true oder false. Standard: false (geschuetzt).
|
||||||
|
# Die KI-Rohantwort wird unabhaengig davon immer in der SQLite-Datenbank gespeichert.
|
||||||
|
log.ai.sensitive=false
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
Du bist ein Assistent für ein deutsches Dokumentenverwaltungssystem.
|
||||||
|
Deine Aufgabe ist es, aus dem Inhalt einer bereits OCR-verarbeiteten PDF-Datei
|
||||||
|
einen aussagekräftigen, kurzen und normierten Dateinamensvorschlag zu erstellen.
|
||||||
|
|
||||||
|
Antworte ausschließlich mit einem validen JSON-Objekt im folgenden Schema:
|
||||||
|
{
|
||||||
|
"date": "YYYY-MM-DD",
|
||||||
|
"title": "Kurztitel auf Deutsch",
|
||||||
|
"reasoning": "Kurze Begründung auf Deutsch"
|
||||||
|
}
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Das Feld "title" ist verpflichtend.
|
||||||
|
- Das Feld "reasoning" ist verpflichtend.
|
||||||
|
- Das Feld "date" ist optional. Wenn kein belastbares Datum aus dem Dokument eindeutig ableitbar ist, lass das Feld weg. Kein Datum erfinden.
|
||||||
|
- Das Datumsformat ist YYYY-MM-DD (z.B. 2026-03-15).
|
||||||
|
- Der Titel ist auf Deutsch, verständlich und eindeutig für den Dokumentinhalt.
|
||||||
|
- Der Titel hat maximal 20 Zeichen (Basistitel ohne Suffix).
|
||||||
|
- Keine generischen Bezeichner wie "Dokument", "Scan", "Datei", "PDF".
|
||||||
|
- Keine Sonderzeichen außer Leerzeichen im Titel.
|
||||||
|
- Eigennamen bleiben unverändert.
|
||||||
|
- Umlaute und ß sind erlaubt.
|
||||||
|
- Kein Text außerhalb des JSON-Objekts.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# V2.0-Freigabe
|
||||||
|
|
||||||
|
## Geprüfter Stand
|
||||||
|
|
||||||
|
- Git-Branch: `main`
|
||||||
|
- Git-Commit (HEAD, zum Zeitpunkt der Prüfung): `1bb7a427357c73039c09a8e1bfe351dee54df765`
|
||||||
|
- Datum der Prüfung: 2026-04-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| Vollständiger Maven-Reactor-Build (`clean verify`, alle 6 Module, `-DskipPitest=true`) | **ERFOLGREICH** |
|
||||||
|
| Unit-Tests gesamt | **1.398 Tests, 0 Failures, 0 Errors, 0 Skipped** |
|
||||||
|
| Integrations-/Smoke-Tests (`ExecutableJarSmokeTestIT`, Bootstrap) | **5 Tests, alle grün** |
|
||||||
|
| Shaded-JAR erzeugt unter `pdf-umbenenner-bootstrap/target/` | **ja** |
|
||||||
|
| Konfigurations- und Dokumentationsbeispiele auf Konsistenz geprüft | **ja** |
|
||||||
|
| Bedienanleitung (`gui-bedienanleitung.md`) gegen reales GUI-Verhalten stichprobengeprüft | **ja – ein während der Prüfung identifizierter Befund wurde unmittelbar korrigiert (siehe R3)** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build- und Test-Ergebnisse
|
||||||
|
|
||||||
|
Ausgeführtes Kommando:
|
||||||
|
```
|
||||||
|
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make -DskipPitest=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gesamtergebnis: BUILD SUCCESS**
|
||||||
|
**Laufzeit: 01:15 min**
|
||||||
|
|
||||||
|
| Modul | Tests | Failures | Errors | Skipped |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `pdf-umbenenner-domain` | 227 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-application` | 455 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-cli` | 8 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-gui` | 190 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-out` | 371 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-bootstrap` (Unit) | 147 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-bootstrap` (IT) | 5 | 0 | 0 | 0 |
|
||||||
|
| **Gesamt** | **1.403** | **0** | **0** | **0** |
|
||||||
|
|
||||||
|
Erzeugtes Shaded-JAR: `pdf-umbenenner-bootstrap/target/pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar`
|
||||||
|
Größe: ca. 28 MB (enthält JavaFX für Windows, PDFBox, SQLite-JDBC, Log4j2 und alle Module)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene nicht blockierende Restpunkte
|
||||||
|
|
||||||
|
Die folgenden Restpunkte wurden in der integrierten Gesamtprüfung dokumentiert und
|
||||||
|
gelten als freigabekompatibel.
|
||||||
|
|
||||||
|
### R1 – ByteBuddy-Agent-Warnung bei Tests
|
||||||
|
|
||||||
|
Beim Build erscheint wiederholt `WARNING: A Java agent has been loaded dynamically
|
||||||
|
(byte-buddy-agent-1.14.12.jar)`. Der Hinweis stammt von Mockito und tritt seit dem V1.1-Stand
|
||||||
|
auf. Kein funktionaler Einfluss. Mit `-XX:+EnableDynamicAgentLoading` unterdrückbar, aber keine
|
||||||
|
Pflicht für V2.0.
|
||||||
|
|
||||||
|
### R2 – GUI-Tests ohne echten JavaFX-Rendering-Pfad
|
||||||
|
|
||||||
|
Die GUI-Tests laufen unter headless JavaFX (Monocle) und prüfen View-Modell-Logik,
|
||||||
|
Zustandsübergänge und Koordinatoren-Verhalten. Das visuelle Rendering (Farbgebung der
|
||||||
|
Meldungspräfixe, Layout-Details) ist nicht automatisiert geprüft. Dies entspricht der in
|
||||||
|
CLAUDE.md definierten GUI-Teststrategie (kein TestFX über Monocle hinaus).
|
||||||
|
|
||||||
|
### R3 – Bedienanleitung: Legacy-Umgebungsvariable für OpenAI-kompatibel (behoben)
|
||||||
|
|
||||||
|
**Ursprünglicher Befund:** Abschnitt 10 der `gui-bedienanleitung.md` beschrieb
|
||||||
|
`OPENAI_COMPATIBLE_API_KEY` fälschlich als Legacy-Umgebungsvariable. Tatsächlich ist
|
||||||
|
`OPENAI_COMPATIBLE_API_KEY` die primäre providerspezifische Umgebungsvariable, und die
|
||||||
|
echte Legacy-Umgebungsvariable lautet `PDF_UMBENENNER_API_KEY`.
|
||||||
|
|
||||||
|
**Status:** Korrigiert. Abschnitt 10 benennt jetzt `OPENAI_COMPATIBLE_API_KEY` als
|
||||||
|
primäre Variable und `PDF_UMBENENNER_API_KEY` als Legacy-Variable und hält fest, dass
|
||||||
|
Claude keine Legacy-Variable hat.
|
||||||
|
|
||||||
|
**Kein Release-Blocker und im V2.0-Freigabestand nicht mehr offen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freigabeaussage
|
||||||
|
|
||||||
|
V2.0 ist nach Prüfung fehlerfrei buildbar, vollständig nach Spezifikation umgesetzt
|
||||||
|
und als freigabefähig einzustufen. Keine Release-Blocker. Die zwei nicht blockierenden
|
||||||
|
Restpunkte (R1, R2) sind dokumentiert und können außerhalb des V2.0-Scopes adressiert
|
||||||
|
werden; R3 wurde während der finalen Prüfung unmittelbar behoben.
|
||||||
|
|
||||||
|
Der vollständige Maven-Reactor-Build ist grün (1.403 Tests, 0 Failures, 0 Errors,
|
||||||
|
0 Skipped). Alle in der integrierten Gesamtprüfung definierten Spezifikations-
|
||||||
|
Prüfpunkte gegen die Spec-Trias sind als erfüllt bestätigt. Die Dokumentation ist
|
||||||
|
vollständig und konsistent mit dem Code.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# V2.9-Freigabe
|
||||||
|
|
||||||
|
## Geprüfter Stand
|
||||||
|
|
||||||
|
- Git-Branch: `main`
|
||||||
|
- Git-Commit (HEAD, zum Zeitpunkt der Prüfung): `6ff463b7efd935960c246dd48f9c55906699a82d`
|
||||||
|
- Datum der Prüfung: 2026-04-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umfang gegenüber V2.0
|
||||||
|
|
||||||
|
V2.9 ist die erste umfangreiche Funktionserweiterung nach dem V2.0-Abschluss.
|
||||||
|
Der Schwerpunkt liegt auf dem neuen Tab „Verarbeitungslauf", der PDF-Vorschau,
|
||||||
|
dem editierbaren Dateinamen-Bereich und der Kommunikation von Verarbeitungsergebnissen
|
||||||
|
an den Benutzer.
|
||||||
|
|
||||||
|
### Neu in V2.9
|
||||||
|
|
||||||
|
| Thema | Issues | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| Tab „Verarbeitungslauf" (Grundstruktur) | #20, #21 | Zweiter Tab mit Ergebnistabelle, Detailbereich und PDF-Vorschau; Anwendungs-Icon und System-Tray |
|
||||||
|
| PDF-Vorschau (PDFBox-Migration) | #27, #29 | Direktes Rendering via `PDFRenderer.renderImageWithDPI`; Lazy Rendering mit In-Memory-Cache; Mausrad-Navigation |
|
||||||
|
| Vollbild-Start | #28 | `stage.setMaximized(true)` beim GUI-Start |
|
||||||
|
| Letzte Konfiguration automatisch laden | #33 | `java.util.prefs.Preferences` (`lastConfigPath`) |
|
||||||
|
| Historischer Dateiname für SKIPPED-Dokumente | #41 | Spalte „Neuer Dateiname" zeigt historischen KI-Vorschlag für übersprungene Einträge |
|
||||||
|
| Detailbereich für SKIPPED-Zeilen | #30 | `GuiHistoricalDocumentContextPort` liefert historischen Kontext; Detailbereich zeigt Datum, Name und Reasoning aus früherem Lauf |
|
||||||
|
| Manuelle Dateinamen-Eingabe (nicht verarbeitete Dateien) | #31 | Dateiname-Editor für `FAILED_RETRYABLE`, `FAILED_PERMANENT`, `SKIPPED_FINAL_FAILURE` zur manuellen Kopie |
|
||||||
|
| Benutzerfreundliche Fehlermeldungen | #43 | `AiFailureMessageTranslator` übersetzt technische Fehler für `FAILED`-Einträge ins Deutsche |
|
||||||
|
| Differenzierte Status-Icons mit Farben | #44 | Unicode-Symbole `✓ ↻ × ≡ ⊘ ⟳` mit farbiger CSS-Darstellung statt Emoji |
|
||||||
|
| Einzelinstanz-Schutz | #35 | Loopback-ServerSocket verhindert parallele Instanzen; zweite Instanz beendet sich sofort |
|
||||||
|
| UX-Fixes im Detailbereich | #39, #40, #45, #46, #47 | Abstände, Button-Deaktivierung, Hinweisbereich |
|
||||||
|
| Konfigurationsbereich kompakter | #24 | Layout-Optimierungen im Konfigurationstab |
|
||||||
|
| Legacy-Datumsformat-Behandlung | #48 | `stringToInstant()`-Fehlerbehandlung; korrekte Abschlussmeldung bei SKIPPED-only-Läufen |
|
||||||
|
| Prompt-Optimierung bei Zeichenlimit | #42 | Prompt weist KI explizit zur Kürzung auf konfiguriertes Zeichenlimit an |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ausgeführte Prüfungen
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| Vollständiger Maven-Reactor-Build (`clean verify`, alle 6 Module, `-DskipPitest=true`) | **ERFOLGREICH** |
|
||||||
|
| Unit-Tests gesamt | **siehe Tabelle** |
|
||||||
|
| Shaded-JAR erzeugt unter `pdf-umbenenner-bootstrap/target/` | **ja** |
|
||||||
|
| Architekturkonsistenz (kein JavaFX in Domain/Application, keine Adapter-zu-Adapter-Abhängigkeiten) | **ja** |
|
||||||
|
| Naming-Regel (keine M/AP/V-Bezeichner in Code) | **ja** |
|
||||||
|
| Dokumentation (`gui-bedienanleitung.md`, `betrieb.md`) auf Konsistenz mit Implementierung geprüft | **ja** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build- und Test-Ergebnisse
|
||||||
|
|
||||||
|
Ausgeführtes Kommando:
|
||||||
|
```
|
||||||
|
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-adapter-in-gui,pdf-umbenenner-bootstrap --also-make -DskipPitest=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gesamtergebnis: BUILD SUCCESS**
|
||||||
|
|
||||||
|
| Modul | Tests | Failures | Errors | Skipped |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `pdf-umbenenner-domain` | 227 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-application` | 455 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-cli` | 8 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-in-gui` | 190 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-adapter-out` | 371 | 0 | 0 | 0 |
|
||||||
|
| `pdf-umbenenner-bootstrap` | 147 | 0 | 0 | 0 |
|
||||||
|
| **Gesamt** | **1.398** | **0** | **0** | **0** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
### #42 – Prompt-Kürzungsverhalten modellabhängig
|
||||||
|
|
||||||
|
Der Prompt weist die KI explizit an, bei Überschreitung des konfigurierten Zeichenlimits
|
||||||
|
den Titel auf die zulässige Länge zu kürzen. Ob das Modell dieser Anweisung zuverlässig
|
||||||
|
folgt, hängt vom eingesetzten Modell ab. Modelle mit schwacher Instruction-Following-Fähigkeit
|
||||||
|
können das Limit ignorieren; in diesem Fall greift die bestehende serverseitige
|
||||||
|
Validierung und der Versuch wird als Fehler klassifiziert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Punkte (für nachfolgende Stufen)
|
||||||
|
|
||||||
|
Die folgenden Issues sind bekannt, aber nicht Release-Blocker für V2.9:
|
||||||
|
|
||||||
|
| Issue | Thema |
|
||||||
|
|---|---|
|
||||||
|
| #7 | Persistenz-Browser / Historienansicht in der GUI |
|
||||||
|
| #22 | Kosten-Tracking und Token-Anzeige |
|
||||||
|
| #23 | Weitere KI-Provider jenseits Claude / OpenAI-kompatibel |
|
||||||
|
| #32 | Platzhalterbild in PDF-Vorschau bei fehlendem/ungültigem PDF |
|
||||||
|
| #34 | Dokumentation des Tab-„Verarbeitungslauf"-Bedienkonzepts vervollständigen |
|
||||||
|
| #44 | Icon-Farben unter bestimmten Windows-Systemthemen prüfen |
|
||||||
|
| #49 | Abbruch eines laufenden Verarbeitungslaufs aus der GUI |
|
||||||
|
| #50 | Fortschrittsanzeige während des Verarbeitungslaufs |
|
||||||
|
| #51 | Filter- und Sortierfunktion in der Ergebnistabelle |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freigabeaussage
|
||||||
|
|
||||||
|
V2.9 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
|
||||||
|
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
|
||||||
|
bleibt unverändert gegenüber V2.0. Keine Release-Blocker.
|
||||||
|
|
||||||
|
Der vollständige Maven-Reactor-Build ist grün (1.398 Tests, 0 Failures, 0 Errors,
|
||||||
|
0 Skipped). Die Dokumentation (`gui-bedienanleitung.md`, `betrieb.md`) ist auf
|
||||||
|
den V2.9-Stand gebracht. Die bekannte Einschränkung (#42) ist dokumentiert
|
||||||
|
und kein funktionaler Defekt.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Freigabedokument V3.0 – PDF-Umbenenner
|
||||||
|
|
||||||
|
## Geprüfter Stand
|
||||||
|
|
||||||
|
- Git-Branch: `main`
|
||||||
|
- Versionsnummer: `3.0.238`
|
||||||
|
- MSI-Datei: `PDF-KI-Renamer-3.0.238.msi`
|
||||||
|
- Freigabedatum: 2026-05-05
|
||||||
|
- **Status:** freigegeben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielsetzung von V3.0
|
||||||
|
|
||||||
|
V3.0 ist kein Wechsel der Kernfunktion, sondern ein gezielter Qualitätssprung in drei
|
||||||
|
Dimensionen: **Infrastruktur** (konsistente Versionierung, Flyway-DB-Migration,
|
||||||
|
Jenkins-Stabilisierung, MSI-Vorbereitung), **Transparenz** (Historien-Tab, differenzierte
|
||||||
|
Fehlerstatus-Darstellung, Lauf-Summary-Banner) und **Bedienkomfort** (Tooltips, Statuszeile,
|
||||||
|
Prompt-Editor). Die fachliche Kernverarbeitung des PDF-Umbenenners – PDF lesen, KI benennen,
|
||||||
|
Zieldatei kopieren – bleibt vollständig unverändert. Es wird kein neues Maven-Modul eingeführt;
|
||||||
|
die hexagonale Architektur bleibt unangetastet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umgesetzte Issues
|
||||||
|
|
||||||
|
| # | Commit | Kategorie | Beschreibung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| #67 | `c6379c0` | Infrastruktur | Konsistente Versionierung via Maven CI-friendly `${revision}`, MANIFEST.MF mit `Implementation-Version`, Fallback `dev` |
|
||||||
|
| #68 | `500a8c5` | Infrastruktur | Jenkins-Build mit `-Drevision`-Übergabe, robuste Shade-JAR-Archivierung mit Bash und `mapfile` |
|
||||||
|
| #49 | `732d00c` | Infrastruktur | Flyway-Integration mit V1-Basisskript, 3-Fall-Strategie (leer / Bestand baselined / regulärer Folgestart), `PRAGMA foreign_keys` per `SQLiteConfig`, Lock-Mechanismus, vollständige Schema-Prüfcheckliste, manuelle Schema-Evolution entfernt |
|
||||||
|
| #51 | `563d9f5` | Fachlich/UX | Einheitliche Status-Darstellung mit Icon, Farbe, Tooltip; `FAILED_RETRYABLE` vs. `FAILED_FINAL` eindeutig differenziert |
|
||||||
|
| #66 | `0fe5359` | UX | Tooltips auf Konfigurationstab, Verarbeitungslauf-Tab und Toolbar; zentrale `GuiTooltipTexts`-Konstantenklasse |
|
||||||
|
| #73 | `dc17824` | GUI | Summary-Banner unterhalb Fortschrittsbalken nach Laufabschluss |
|
||||||
|
| #50 | `4f5ce4c` | GUI | Statuszeile mit Version, Provider/Modell und Konfigurationsdateipfad |
|
||||||
|
| #71 | `5d5dee0` | GUI | Prompt-Editor-Tab mit atomarem Speichern (`ATOMIC_MOVE`), Dirty-State, Default-Reset |
|
||||||
|
| #7 | `46fc1d4` | GUI | Historien-Tab mit Liste, Detail, Filter, Status-Reset (feldgenau, Versuche bleiben) und destruktivem Löschen (Attempts vor Record in Transaktion) |
|
||||||
|
| #65 | `51d6168` | Infrastruktur | MSI-Vorbereitung: jdeps-Modulliste, BAT-Dateien, `winUpgradeUuid`, Pfad-Hinweise in `betrieb.md` |
|
||||||
|
|
||||||
|
### Weitere Commits
|
||||||
|
|
||||||
|
| Commit | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| `6e03093` | Architektur-Übersichten ergänzt (`domain-overview.md`, `gui-overview.md`, `adapter-overview.md`) |
|
||||||
|
| `4b89743` | Bedienanleitung auf neuen Stand gebracht |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Bilanz
|
||||||
|
|
||||||
|
| Neu | Anzahl | Bemerkung |
|
||||||
|
|---|---|---|
|
||||||
|
| Outbound-Ports | 1 | `HistoryQueryPort` |
|
||||||
|
| Application-Use-Cases | 5 | `DefaultPromptEditorUseCase`, `DefaultHistoryOverviewUseCase`, `DefaultHistoryDetailsUseCase`, `DefaultHistoryResetDocumentStatusUseCase`, `DefaultDeleteDocumentHistoryUseCase` |
|
||||||
|
| Outbound-Adapter | 2 | `SqliteHistoryQueryAdapter`, `FilesystemPromptPortAdapter.savePrompt` |
|
||||||
|
| GUI-Bridge-Interfaces | 5 | `GuiPromptEditorPort`, `GuiHistoryOverviewPort`, `GuiHistoryDetailsPort`, `GuiHistoryResetDocumentStatusPort`, `GuiDeleteDocumentHistoryPort` |
|
||||||
|
| GUI-Tabs | 2 | „Verlauf", „Prompt" |
|
||||||
|
| GUI-Komponenten | 5 | `GuiStatusBar`, `BatchRunSummaryBanner`, `GuiHistoryTab`, `GuiPromptEditorTab`, `ProcessingStatusPresentation` |
|
||||||
|
| Bootstrap | 1 + Erweiterung | `ApplicationVersionProvider` und Erweiterung des `GuiStartupContext` (`applicationVersion`, 5 neue Port-Felder) |
|
||||||
|
| Datenbank-Migration | – | Flyway-V1-Basisskript, 3-Fall-Strategie, FK-Pragma per `SQLiteConfig`, Lock-Mechanismus |
|
||||||
|
|
||||||
|
Nicht geändert: `pdf-umbenenner-domain`, `pdf-umbenenner-adapter-in-cli`, headless-Betrieb.
|
||||||
|
Bootstrap ausschließlich um MANIFEST.MF-Einträge und neue Bridge-Verdrahtung erweitert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbindlich verifizierte Spec-Punkte
|
||||||
|
|
||||||
|
- `${revision}` wird durch `flatten-maven-plugin` (`resolveCiFriendliesOnly`) aufgelöst;
|
||||||
|
installierte POMs enthalten kein unaufgelöstes `${revision}`
|
||||||
|
- MANIFEST.MF im Fat-JAR trägt `Implementation-Version`; Laufzeit-Fallback ist `dev`
|
||||||
|
- `evolveTableColumns()` vollständig aus dem Code entfernt; Flyway ist die einzige
|
||||||
|
Schema-Evolutionsquelle
|
||||||
|
- Status-Reset setzt feldgenau `overall_status='READY_FOR_AI'`,
|
||||||
|
`content_error_count=0`, `transient_error_count=0`, `last_failure_instant=NULL`;
|
||||||
|
Versuche (`processing_attempt`) bleiben vollständig unangetastet
|
||||||
|
- Tab-Reihenfolge: `Konfiguration | Verarbeitungslauf | Verlauf | Prompt`
|
||||||
|
- `PromptPort.savePrompt` bleibt pfadfrei in der Port-Signatur (Hexagonal-konform;
|
||||||
|
Pfadauflösung liegt im Adapter)
|
||||||
|
- Farbe ist niemals das einzige Unterscheidungsmerkmal; alle Status tragen Icon und Text
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Headless-Kompatibilität
|
||||||
|
|
||||||
|
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten. Die
|
||||||
|
`.properties`-Datei bleibt die einzige Konfigurationswahrheit. GUI-Code initialisiert
|
||||||
|
den headless Pfad nicht. Keine stillen Änderungen an Retry-Semantik, Status-Persistenz
|
||||||
|
oder fachlicher Verarbeitungslogik.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank-Migration
|
||||||
|
|
||||||
|
Bestehende Datenbestände aus dem Vorgängerstand werden beim ersten Start der 3-Fall-Strategie
|
||||||
|
unterworfen:
|
||||||
|
|
||||||
|
- **Neue DB** (keine Tabellen vorhanden): Flyway führt `V1__initial_schema.sql` vollständig aus.
|
||||||
|
- **Bestand ohne Flyway-History** (typische Vorgänger-DB): vollständige Schema-Prüfcheckliste
|
||||||
|
gegen das V1-Zielschema; bei konformem Schema wird eine datierte Backup-Kopie der
|
||||||
|
`.sqlite`-Datei erstellt, danach Baseline auf V1 gesetzt. Bei nicht konformem Schema
|
||||||
|
bricht der Start mit klarer Fehlermeldung ab – kein stilles Weiterlaufen.
|
||||||
|
- **Bestand mit Flyway-History** (regulärer Folgestart): `migrate()` läuft idempotent.
|
||||||
|
|
||||||
|
`baselineOnMigrate=true` wird ausschließlich in Fall 2 gesetzt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Punkte (vor finalem Release)
|
||||||
|
|
||||||
|
| Thema | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| MSI-Testmatrix | Manueller MSI-Build und vollständige Abarbeitung der Testmatrix auf Windows-Maschine erforderlich; insbesondere Anwendungsstart **ohne JDK** zur Verifikation der `addModules`-Liste |
|
||||||
|
| `winUpgradeUuid` | Der GUID `EA8D0149-1401-4D3D-A98D-A2B98DAE5495` wurde im Rahmen von #65 neu generiert. Vor dem ersten produktiven MSI-Release ist sicherzustellen, dass kein bisheriges produktives MSI mit einem abweichenden GUID ausgeliefert wurde – andernfalls bricht der MSI-Upgrade-Mechanismus. Nach Bestätigung „nie produktiv ausgeliefert" ist der GUID damit gesetzt und darf nie wieder geändert werden. |
|
||||||
|
| Manueller GUI-Produkttest | Erfolgreicher Build und grüne Tests ersetzen keinen End-to-End-Lauf gegen einen echten KI-Provider mit echten PDFs. |
|
||||||
|
| Finale Freigabe | `freigabe-v3_0.md` wird nach abgeschlossenem manuellen Produkttest und MSI-Verifikation in den Status „freigegeben" überführt. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in V3.0
|
||||||
|
|
||||||
|
- Automatischer Scheduler / Quellordner-Überwachung
|
||||||
|
- Token- und Kosten-Tracking
|
||||||
|
- Excel-Export
|
||||||
|
- Automatische Update-Prüfung
|
||||||
|
- Dark Mode
|
||||||
|
- Log-Viewer
|
||||||
|
- PDF-Viewer Render-DPI-Konfiguration
|
||||||
|
- Zoom per Mausrad
|
||||||
|
- Hilfe-Datei F1
|
||||||
|
- Änderungen an der fachlichen Kernverarbeitung des PDF-Umbenenners
|
||||||
|
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freigabeaussage
|
||||||
|
|
||||||
|
V3.0 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
|
||||||
|
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
|
||||||
|
bleibt unverändert gegenüber dem Vorgängerstand. Keine Release-Blocker für die
|
||||||
|
Implementierungs-Freigabe.
|
||||||
|
|
||||||
|
Die finale Release-Freigabe steht aus bis zur vollständigen Abarbeitung der
|
||||||
|
MSI-Testmatrix (insbesondere Verifikation des Anwendungsstarts ohne JDK),
|
||||||
|
Klärung des `winUpgradeUuid`-Erstauslieferungsstatus und abgeschlossenem
|
||||||
|
manuellem GUI-Produkttest gegen einen echten KI-Provider.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Freigabedokument V3.1 – PDF-Umbenenner
|
||||||
|
|
||||||
|
## Geprüfter Stand
|
||||||
|
|
||||||
|
- Git-Branch: `main`
|
||||||
|
- Versionsnummer: `3.1.267`
|
||||||
|
- Freigabedatum: 2026-05-06
|
||||||
|
- **Status:** freigegeben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielsetzung von V3.1
|
||||||
|
|
||||||
|
V3.1 ist der konsequente Nachschlag zu V3.0: Was der Produkttest aufgedeckt hat,
|
||||||
|
wird bereinigt. Kein großes Architektur-Feature, kein neues Maven-Modul –
|
||||||
|
gezielter UX-Schliff und Robustheit in drei Schwerpunkten:
|
||||||
|
|
||||||
|
1. **UX-Polishing** – sichtbare Schwächen aus dem V3.0-Produkttest behoben
|
||||||
|
(#77, #80, #81, #83, #84, #88, #91)
|
||||||
|
2. **Verlauf-Tab reifen lassen** – Suche, Mehrfachauswahl, DB-Neuanlage
|
||||||
|
(#82, #86, #87)
|
||||||
|
3. **Quick Win** – Mausrad-Zoom im PDF-Viewer als wertvoller Gebrauchskomfort
|
||||||
|
(#32)
|
||||||
|
|
||||||
|
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt vollständig unverändert.
|
||||||
|
Hexagonale Architektur, Modulstruktur, headless-Betrieb, `.properties`-
|
||||||
|
Konfigurationswahrheit und Flyway-DB-Evolution bleiben unangetastet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umgesetzte Issues
|
||||||
|
|
||||||
|
| # | Kategorie | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| #32 | GUI | Strg+Mausrad-Zoom in der PDF-Vorschau: Delta-Akkumulation für Trackpad-Kompatibilität, ScrollEvent bei Strg immer konsumiert, Zoom 10–500 %, Viewport-Mitte bleibt beim Zoom stabil, Fit-to-Width-Modus nach manuellem Zoom verlassen; Grab & Pan mit Handcursor im vergrößerten Zustand |
|
||||||
|
| #77 | UX | Vollständige Bestandsaufnahme aller interaktiven Elemente auf allen Tabs; fehlende Tooltips auf allen vier Tabs ergänzt; neue Konstanten ausschließlich in `GuiTooltipTexts`; TableColumn-Header über Column-Graphic-Pattern mit Label und Tooltip (kein Skin-/Lookup-Hack) |
|
||||||
|
| #80 | UX | Dirty-Indikator für den Konfigurations-Tab: Asterisk im Tab-Titel bei echter Nutzeränderung gegenüber Baseline-Snapshot; `loadingInProgress`-Flag verhindert unechte Dirty-State-Auslösung durch programmgesteuertes Laden; Bestätigungsdialog beim Verlassen mit ungespeicherten Änderungen; Kopplung mit DB-Pfad-Wechsel aus #87 |
|
||||||
|
| #81 | UX | Status-ComboBox und Versuche-Tabelle zeigen lesbare deutsche Anzeigetexte statt Enum-Rohnamen; alle acht Statuswerte über `ProcessingStatusPresentation` abgebildet; Status-ComboBox mit „Alle Status" als GUI-internem Null-Filter; DB-Queries intern weiterhin mit Enum-Namen |
|
||||||
|
| #82 | GUI | Live-Filter im Verlauf-Tab: 300 ms Debounce-Timer, Generation-Counter für Race-Condition-Schutz, veraltete Worker-Ergebnisse werden verworfen; Such-Button und Enter starten Suche sofort; Auswahl nach jeder neuen Suche vollständig geleert |
|
||||||
|
| #83 | UX | Leere KI-Begründung im Detailbereich zeigt `promptText`-Platzhalter statt leerem Feld; kein Vermischen von Nutzdaten und UI-Platzhaltertext; TextArea bleibt sichtbar |
|
||||||
|
| #84 | Bug | Aktionsbuttons im Verlauf-Tab werden nach Laufende ereignisgetrieben reaktiviert – unabhängig vom Terminierungsgrund (Erfolg, Fehlerabbruch, Nutzerabbruch, Leerlauf); kein manueller Workaround notwendig |
|
||||||
|
| #86 | GUI | Mehrfachauswahl im Verlauf-Tab: `SelectionMode.MULTIPLE`, Strg+A nur bei Tabellenfokus (kein Konflikt mit Suchfeld), Schlüssel-Snapshot vor Worker-Thread-Start, Bulk-Reset und Bulk-Delete mit Bestätigungsdialog und Partial-Success-Zusammenfassung; Detailbereich zeigt Platzhalter bei Mehrfachauswahl |
|
||||||
|
| #87 | GUI | Neuer Menüpunkt „Datenbank → Neue Datenbank anlegen...": atomarer Ablauf via Temp-Datei, Flyway auf neuesten Schema-Stand, Verbindungstest, atomarer Move mit `ATOMIC_MOVE + REPLACE_EXISTING`; normalisierter case-insensitiver Pfadvergleich; DB-Busy-Sperre; Konfig-Tab wechselt in Dirty-State; Hinweismeldung nach Wechsel |
|
||||||
|
| #88 | UX | Fehlerursache für `FAILED_FINAL`, `FAILED_RETRYABLE` und `SKIPPED_FINAL_FAILURE` im Verlauf-Tab sichtbar; Flyway-Migration ergänzt Spalte `failure_details` in `processing_attempt`; Begrenzung auf 1000 Zeichen mit „…"-Kürzung vor Persistierung; keine rohen Provider-Meldungen oder API-Schlüssel persistiert; NULL-Einträge zeigen `promptText`-Platzhalter |
|
||||||
|
| #91 | Robustheit | Lock-File-Pfadauflösung: absoluter Pfad direkt ohne Fallback (Abbruch bei Fehler); relativer oder unkonfigurierter Pfad zweistufig (JAR-Verzeichnis → `user.home` → Abbruch); fehlende Parent-Verzeichnisse automatisch angelegt; tatsächlich verwendeter absoluter Pfad beim Start auf INFO-Level geloggt; gilt für GUI- und headless Start |
|
||||||
|
|
||||||
|
### Nachbesserung aus dem Produkttest
|
||||||
|
|
||||||
|
| # | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| #93 | Produkttest-Nachbesserung: Korrekturen und Feinabstimmungen nach abgeschlossenem manuellem GUI-Produkttest gegen echte KI-Provider und echte PDFs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Bilanz
|
||||||
|
|
||||||
|
| Neu | Anzahl | Bemerkung |
|
||||||
|
|---|---|---|
|
||||||
|
| Inbound-Port-Interfaces | 1 | `CreateNewDatabaseUseCase` |
|
||||||
|
| Application-Use-Cases | 1 | `DefaultCreateNewDatabaseUseCase` |
|
||||||
|
| Outbound-Ports | 2 | `DatabaseCreationPort`, `ActiveDatabaseContextPort` |
|
||||||
|
| Outbound-Adapter | 2 | `SqliteDatabaseCreationAdapter`, `SqliteActiveDatabaseContextAdapter` |
|
||||||
|
| GUI-Bridge-Interfaces | 1 | `GuiCreateNewDatabasePort` |
|
||||||
|
| Flyway-Migration | 1 | `failure_details TEXT` in `processing_attempt` (nächste freie Versionsnummer) |
|
||||||
|
|
||||||
|
Geänderte Komponenten (ausschließlich `adapter-in-gui`):
|
||||||
|
`GuiHistoryTab`, `GuiConfigTab`, `GuiTooltipTexts`, Verlauf-Detailbereich,
|
||||||
|
Status-ComboBox, PDF-Vorschau-Komponente, Lauf-Abschluss-Signalkette.
|
||||||
|
|
||||||
|
Nicht geändert: `pdf-umbenenner-domain` (außer ggf. minimaler Erweiterung für #88),
|
||||||
|
`pdf-umbenenner-adapter-in-cli`, headless-Verarbeitungslogik, fachliche Kernverarbeitung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbindlich verifizierte Spec-Punkte
|
||||||
|
|
||||||
|
- Kein Enum-Rohname in der GUI sichtbar – alle acht Statuswerte tragen Displaytext
|
||||||
|
- `promptText` für leere Felder: kein Vermischen von Nutzdaten und Platzhaltertext
|
||||||
|
- Dirty-State Konfig-Tab: programmgesteuertes Laden löst kein Dirty-Flag aus
|
||||||
|
- Live-Filter: 300 ms Debounce, Generation-Counter, Auswahl nach Suche geleert
|
||||||
|
- Strg+A im Verlauf-Tab: nur bei Tabellenfokus (kein Konflikt mit Suchfeld)
|
||||||
|
- Schlüssel-Snapshot vor Bulk-Worker-Thread-Start
|
||||||
|
- DB-Anlage: normalisierter Pfadvergleich (case-insensitive, `toRealPath`/Parent-Normalisierung)
|
||||||
|
- DB-Anlage: `ATOMIC_MOVE + REPLACE_EXISTING`; kein halb-atomarer Fallback
|
||||||
|
- DB-Anlage: aktive DB bleibt bei Fehler vollständig unverändert
|
||||||
|
- Lock-File: absoluter Pfad direkt; relativer Pfad zweistufig; Pfad geloggt (INFO)
|
||||||
|
- Strg+Mausrad: ScrollEvent immer konsumiert; Delta-Akkumulation; 10–500 %
|
||||||
|
- `failure_details`: max. 1000 Zeichen vor Persistierung; keine rohen Provider-Meldungen
|
||||||
|
- Aktionsbuttons nach Laufende ereignisgetrieben reaktiviert (alle Terminierungsgründe)
|
||||||
|
- Flyway ist die einzige Schema-Evolutionsquelle – kein manuelles DDL im Code
|
||||||
|
- Code-Kommentare auf Deutsch; Logging auf Deutsch
|
||||||
|
- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Headless-Kompatibilität
|
||||||
|
|
||||||
|
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten.
|
||||||
|
Die `.properties`-Datei bleibt die einzige Konfigurationswahrheit. GUI-Code
|
||||||
|
initialisiert den headless Pfad nicht. Keine stillen Änderungen an Retry-Semantik,
|
||||||
|
Status-Persistenz oder fachlicher Verarbeitungslogik.
|
||||||
|
|
||||||
|
Von V3.1-Änderungen betroffener headless-Pfad: Lock-File-Pfadauflösung (#91)
|
||||||
|
und Flyway-Schemamigration für `failure_details` (#88) – beide wirken beim
|
||||||
|
Programmstart unabhängig von GUI oder CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank-Migration
|
||||||
|
|
||||||
|
Flyway ergänzt die Tabelle `processing_attempt` um die Spalte `failure_details`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Bestehende Zeilen erhalten automatisch `NULL` – kein Datenverlust.
|
||||||
|
- Ältere Einträge ohne Fehlerdetails zeigen in der GUI einen `promptText`-Platzhalter.
|
||||||
|
- Kein SQL-`CHECK`-Constraint (um Importdaten nicht zu blockieren).
|
||||||
|
- Begrenzung auf 1000 Zeichen wird ausschließlich vor Persistierung im Adapter erzwungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Produkttest
|
||||||
|
|
||||||
|
**Produkttest: bestanden**
|
||||||
|
|
||||||
|
Manueller GUI-Produkttest gegen echte KI-Provider mit echten PDFs abgeschlossen.
|
||||||
|
Alle elf Issues und die Nachbesserung #93 wurden end-to-end verifiziert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in V3.1
|
||||||
|
|
||||||
|
- Automatischer Scheduler / Quellordner-Überwachung (#22) → V3.x
|
||||||
|
- PDF-Viewer Render-DPI (#23) → V3.2
|
||||||
|
- F1-Hilfe (#69) → V3.2
|
||||||
|
- Dark Mode (#70) → V3.x
|
||||||
|
- Log-Viewer in der GUI (#72) → V3.2
|
||||||
|
- Token- und Kosten-Tracking (#74) → V3.2
|
||||||
|
- Excel-Export (#75) → V3.2
|
||||||
|
- Automatische Update-Prüfung (#76) → V3.2
|
||||||
|
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
|
||||||
|
- Änderung der fachlichen Kernverarbeitung des PDF-Umbenenners
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Version
|
||||||
|
|
||||||
|
**V3.2** – geplante Schwerpunkte: PDF-Viewer Render-DPI, F1-Hilfe, Log-Viewer,
|
||||||
|
Token- und Kosten-Tracking, Excel-Export, automatische Update-Prüfung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freigabeaussage
|
||||||
|
|
||||||
|
V3.1 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der hexagonalen
|
||||||
|
Architektur sind eingehalten. Die fachliche Kernverarbeitung des PDF-Umbenenners
|
||||||
|
bleibt unverändert gegenüber V3.0. Manueller Produkttest bestanden.
|
||||||
|
Keine Release-Blocker.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Freigabedokument V3.2 – PDF-Umbenenner
|
||||||
|
|
||||||
|
## Geprüfter Stand
|
||||||
|
|
||||||
|
- Git-Branch: `main`
|
||||||
|
- Versionsnummer: `3.2.300`
|
||||||
|
- Freigabedatum: 2026-05-07
|
||||||
|
- **Status:** freigegeben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielsetzung von V3.2
|
||||||
|
|
||||||
|
V3.2 ist der Übergang vom manuellen Batch-Tool zur autonomen
|
||||||
|
Dauerläufer-Anwendung. Ein einziges, klar abgegrenztes Hauptfeature:
|
||||||
|
|
||||||
|
**#22 – Automatischer Scheduler:** Die Anwendung überwacht den konfigurierten
|
||||||
|
Quellordner dauerhaft im Hintergrund und startet die Verarbeitungspipeline
|
||||||
|
automatisch, sobald neue PDF-Dateien erkannt werden. Der Nutzer steuert
|
||||||
|
den Scheduler ausschließlich über den neuen Tab „Scheduler".
|
||||||
|
|
||||||
|
V3.2 ist eine reine Scheduler-Veranstaltung. Token- und Kosten-Tracking (#74)
|
||||||
|
wurde bewusst herausgelöst und bekommt eine eigene saubere Spezifikation in
|
||||||
|
V3.x – inklusive Modell-Preistabelle, Persistenz-Strategie und EUR-Währung.
|
||||||
|
|
||||||
|
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt vollständig
|
||||||
|
unverändert. Hexagonale Architektur, Modulstruktur, headless-Betrieb,
|
||||||
|
`.properties`-Konfigurationswahrheit und Flyway-DB-Evolution bleiben
|
||||||
|
unangetastet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umgesetzte Features
|
||||||
|
|
||||||
|
| # | Kategorie | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| #22 | Hauptfeature | Automatischer Scheduler: `ScheduledExecutorService`-Polling mit `scheduleWithFixedDelay`; Initial Delay 0 (erster Tick sofort); konfigurierbares Intervall (Minimum 30 s); neuer Tab „Scheduler" mit Start/Stop, Statusanzeige, Countdown, letzter Lauf, Gesamtzähler; OS-Lock auf `.properties` während Scheduler läuft; Konfig-Tab read-only bei aktivem Lock; manuelle Läufe bei aktivem Scheduler gesperrt; App-Schließen-Guard |
|
||||||
|
|
||||||
|
### Neue Architektur-Komponenten
|
||||||
|
|
||||||
|
| Neu | Anzahl | Bemerkung |
|
||||||
|
|---|---|---|
|
||||||
|
| Neues Maven-Modul | 1 | `pdf-umbenenner-adapter-in-scheduler` |
|
||||||
|
| Inbound-Port-Interfaces | 1 | `SchedulerControlUseCase` |
|
||||||
|
| Application-Use-Cases | 1 | `DefaultSchedulerControlUseCase` |
|
||||||
|
| Outbound-Ports | 3 | `SchedulerPort`, `ConfigurationFileLockPort`, `SchedulerSettingsPort` |
|
||||||
|
| Funktionale Interfaces | 1 | `BatchRunTrigger` mit sealed `BatchRunTriggerResult` |
|
||||||
|
| Neue Adapter | 2 | `ScheduledExecutorServiceSchedulerAdapter`, `FileChannelConfigurationAccessAdapter` |
|
||||||
|
| GUI-Komponenten neu | 2 | `GuiSchedulerTab`, `GuiStatusRefreshTimeline` |
|
||||||
|
| Bootstrap-Refactoring | – | Init/Run-Trennung: `GuiShellContext` immer, `ApplicationRunContext` bei valider Config; `GuiApplicationContextInitializer`-Callback für Auto-Load-Pfad |
|
||||||
|
| Flyway-Migration | 0 | Keine DB-Migration in V3.2 |
|
||||||
|
|
||||||
|
Kontrollierte Architekturausnahme: CLAUDE.md wurde um die Scheduler-Ausnahme
|
||||||
|
erweitert. „Keine Dauerlauf-Anwendung" und „kein interner Scheduler" gelten
|
||||||
|
ab V3.2 nur noch für den headless-Pfad.
|
||||||
|
|
||||||
|
### Zusätzliche Verbesserungen (Produkttest-Nachbesserungen)
|
||||||
|
|
||||||
|
| Beschreibung |
|
||||||
|
|---|
|
||||||
|
| `ApplicationRunContext` wird nun auch beim Auto-Load-Pfad (ohne `--config`) korrekt aufgebaut via `GuiApplicationContextInitializer`-Callback |
|
||||||
|
| Double-Lock-Bug im `BatchRunTrigger`-Lambda behoben: kein eigenes `tryAcquire()` mehr, Lock ausschließlich in `execute()` |
|
||||||
|
| Stop-Button-Wiring-Bug behoben: `GuiStatusRefreshTimeline` liest jetzt den Live-Use-Case aus dem Workspace statt aus dem unveränderlichen `GuiStartupContext` |
|
||||||
|
| `installSchedulerCloseGuard` analog gefixt (gleiches Wiring-Problem) |
|
||||||
|
| `loadHistoryOverviewForGui` und 6 weitere GUI-Methoden im `BootstrapRunner` nutzen bei vorhandenem `ApplicationRunContext` direkt den Repository-Adapter statt Config neu zu laden – verhindert IOException bei aktivem Config-Lock |
|
||||||
|
| Autostart-Feature entfernt: Scheduler startet nie automatisch, immer nur auf explizite Nutzeraktion |
|
||||||
|
| `RunSummary`-Zählung im Scheduler-Tab korrigiert: `PROPOSAL_READY` zählt korrekt als Erfolg; Gesamtzähler seit Scheduler-Start eingeführt |
|
||||||
|
| Java-Preferences-Knoten auf fixen String `de/gecheckt/pdf-umbenenner` umgestellt – verhindert Verlust des gespeicherten Config-Pfads nach Code-Änderungen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbindlich verifizierte Spec-Punkte
|
||||||
|
|
||||||
|
- Scheduler startet nur auf explizite Nutzeraktion – kein Autostart
|
||||||
|
- Erster Tick läuft sofort nach Scheduler-Start (Initial Delay 0)
|
||||||
|
- `scheduleWithFixedDelay`: nächster Tick erst N Sekunden nach Laufende
|
||||||
|
- Laufkollision via nicht-blockierendem `RunLockPort.tryAcquire()` – kein Queuing
|
||||||
|
- Manuelle Läufe bei aktivem Scheduler gesperrt (deterministisches Verhalten)
|
||||||
|
- OS-Lock auf `.properties` während Scheduler läuft: Konfig-Tab read-only,
|
||||||
|
Speichern-Button deaktiviert, Eingabefelder nicht editierbar
|
||||||
|
- Verlauf-Tab funktioniert korrekt bei aktivem Config-Lock
|
||||||
|
- Stop während aktivem Lauf: Batch läuft zu Ende, danach `STOPPED`
|
||||||
|
- App-Schließen bei aktivem Scheduler: Hinweisdialog, App schließt nicht
|
||||||
|
- `SchedulerStatus` als immutable Snapshot via `AtomicReference`
|
||||||
|
- `SchedulerState` mit 5 Werten: `STOPPED`, `STARTING`, `RUNNING_IDLE`,
|
||||||
|
`RUNNING_BATCH_ACTIVE`, `STOPPING_BATCH_ACTIVE`
|
||||||
|
- No-op-Lauf (keine Kandidaten): „keine neuen Dokumente"; kein Fehlerstatus
|
||||||
|
- Scheduler-Tab zeigt korrekte Anzeige: letzter Lauf + Gesamtzähler
|
||||||
|
- Exception im Tick: gefangen, ERROR-geloggt, Executor läuft weiter
|
||||||
|
- Non-Daemon-Thread; sauberer Shutdown via `awaitTermination`
|
||||||
|
- Kein JavaFX im Modul `adapter-in-scheduler`
|
||||||
|
- PIT im neuen Modul explizit deaktiviert
|
||||||
|
- Code-Kommentare auf Deutsch; Logging auf Deutsch
|
||||||
|
- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
|
||||||
|
- Flyway ist die einzige Schema-Evolutionsquelle – keine Migration in V3.2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Headless-Kompatibilität
|
||||||
|
|
||||||
|
Der bestehende Batch-Betrieb über `--headless` bleibt vollständig erhalten.
|
||||||
|
Scheduler-Properties (`scheduler.enabled`, `scheduler.interval.seconds`)
|
||||||
|
werden im headless-Modus weder gelesen noch validiert. Der headless-Pfad
|
||||||
|
verwendet keinen Scheduler-Codepfad und keinen Config-Lock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank-Migration
|
||||||
|
|
||||||
|
**Keine.** Das DB-Schema bleibt unverändert auf V1 (`V1__initial_schema.sql`).
|
||||||
|
Es wurden keine neuen Spalten und keine neuen Tabellen angelegt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Produkttest
|
||||||
|
|
||||||
|
**Produkttest: bestanden**
|
||||||
|
|
||||||
|
Manueller GUI-Produkttest gegen echten KI-Provider mit echten PDFs
|
||||||
|
abgeschlossen. Der Scheduler hat PDFs automatisch erkannt, per KI benannt
|
||||||
|
und in den Zielordner verschoben – vollautomatisch ohne Nutzeraktion.
|
||||||
|
Alle wesentlichen Szenarien (Start/Stop, No-op-Lauf, aktive Verarbeitung,
|
||||||
|
Verlauf-Tab bei aktivem Scheduler, App-Schließen-Guard) wurden verifiziert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
| Einschränkung | Bewertung |
|
||||||
|
|---|---|
|
||||||
|
| JavaFX `NullPointerException` beim Schließen (`GraphicsPipeline.getPipeline() == null`) | JavaFX-interner Fehler nach Shutdown; kein Fehler im Anwendungscode; kein Datenverlust; kein Handlungsbedarf |
|
||||||
|
| Unvollständige PDFs (noch im Kopiervorgang) können temporär `FAILED_RETRYABLE` erzeugen | Erwartet; bestehende Retry-Semantik behandelt das korrekt beim nächsten Tick |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in V3.2
|
||||||
|
|
||||||
|
- Token- und Kosten-Tracking (#74) → V3.x (eigene Spezifikation mit
|
||||||
|
Modell-Preistabelle, Persistenz-Strategie, EUR-Währung)
|
||||||
|
- Headless-Daemon-Betrieb des Schedulers (`--watch`-Flag) → V3.x
|
||||||
|
- Java WatchService (ereignisgesteuerte Ordnerüberwachung) → V3.x
|
||||||
|
- Windows-Service-Integration (WinSW o.ä.) → V3.x
|
||||||
|
- Modell-Filterung (OpenAI-Snapshots ausblenden) → V3.x
|
||||||
|
- Dark Mode (#70) → V3.x
|
||||||
|
- F1-Hilfe (#69) → V3.x
|
||||||
|
- Log-Viewer in der GUI (#72) → V3.x
|
||||||
|
- Excel-Export (#75) → V3.x
|
||||||
|
- Automatische Update-Prüfung (#76) → V3.x
|
||||||
|
- Neue KI-Provider, Architekturbrüche
|
||||||
|
- Änderung der fachlichen Kernverarbeitung des PDF-Umbenenners
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Version
|
||||||
|
|
||||||
|
**V3.x** – Token- und Kosten-Tracking als eigenständiges, vollständig
|
||||||
|
durchdachtes Feature: Modell-Preistabelle (pro Modell, nicht pro Provider),
|
||||||
|
EUR-Währung, Kostenanzeige im Summary-Banner, Modell-Filterung für
|
||||||
|
OpenAI-kompatible Provider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Freigabeaussage
|
||||||
|
|
||||||
|
V3.2 ist nach Prüfung fehlerfrei buildbar. Alle Kernanforderungen der
|
||||||
|
hexagonalen Architektur sind eingehalten. Das neue Modul `adapter-in-scheduler`
|
||||||
|
ist korrekt eingebunden (kein JavaFX, PIT deaktiviert, flatten aktiv).
|
||||||
|
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert
|
||||||
|
gegenüber V3.1. Headless-Betrieb vollständig unberührt. Manueller
|
||||||
|
Produkttest bestanden. Keine Release-Blocker.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
|||||||
|
# V2.6 – Titellänge parametrisierbar machen
|
||||||
|
|
||||||
|
**Status:** Entwurf
|
||||||
|
**Erstellt:** 2026-04-22
|
||||||
|
**Autor:** Marcus (mit Claude als Mentor)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Der maximale Basistitel für KI-generierte PDF-Namen wird nicht mehr hardcodiert,
|
||||||
|
sondern ist über die Konfigurationsdatei steuerbar. Alle bisherigen Magic Numbers
|
||||||
|
(20 und 60 Zeichen) werden durch den konfigurierten Wert ersetzt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hintergrund
|
||||||
|
|
||||||
|
### Bisheriger Zustand
|
||||||
|
- Titellänge war mit 20 Zeichen im Prompt und 60 Zeichen in der Validierung hardcodiert
|
||||||
|
- Kein zentraler Konfigurationsparameter, Werte über ~20 Dateien verstreut
|
||||||
|
- 60-Zeichen-Limit wurde im Rahmen des Produkttests als pragmatischer Zwischenwert eingeführt
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
- Verschiedene Einsatzszenarien erfordern unterschiedliche Titellängen
|
||||||
|
- Dateinamenlimits je nach Zielsystem unterschiedlich (siehe Recherche unten)
|
||||||
|
|
||||||
|
### Recherchierte Dateinamenlimits (nur Dateiname, ohne Pfad)
|
||||||
|
|
||||||
|
| System | Limit |
|
||||||
|
|---|---|
|
||||||
|
| Windows 10 / Windows Server 2022 (NTFS) | 255 Zeichen |
|
||||||
|
| Synology NAS – Btrfs (unverschlüsselt) | 255 Zeichen |
|
||||||
|
| Synology NAS – Btrfs (verschlüsselt) | ~143 Zeichen |
|
||||||
|
|
||||||
|
**Hinweis:** Der generierte Dateiname hat das Format `YYYY-MM-DD - <Titel>.pdf`,
|
||||||
|
was bereits 18 Zeichen Overhead bedeutet (Datum + Trennzeichen + Dateiendung).
|
||||||
|
Das sicherste Maximum für verschlüsselte Synology-Volumes ist daher **120 Zeichen**
|
||||||
|
für den Basistitel (143 − 18 = 125, mit Puffer auf 120 gerundet).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fachliche Anforderungen
|
||||||
|
|
||||||
|
### Neuer Konfigurationsparameter
|
||||||
|
|
||||||
|
- **Name:** `ai.title.max.length` (finale Benennung obliegt der Implementierung)
|
||||||
|
- **Typ:** positive Ganzzahl
|
||||||
|
- **Defaultwert:** `60` (bisheriger Wert bleibt erhalten, kein Breaking Change)
|
||||||
|
- **Speicherort:** `.properties`-Konfigurationsdatei
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Validierungsregeln
|
||||||
|
|
||||||
|
| Wert | Typ | Verhalten |
|
||||||
|
|---|---|---|
|
||||||
|
| Kein Wert / leer | Fehler | Pflichtfeld, Start wird abgebrochen |
|
||||||
|
| Keine Ganzzahl (z. B. „abc", „1.5") | Fehler | Ungültiger Typ, Start wird abgebrochen |
|
||||||
|
| < 1 | Fehler | Wert muss positiv sein, Start wird abgebrochen |
|
||||||
|
| 1–9 | Fehler | Minimum ist 10 Zeichen, Start wird abgebrochen |
|
||||||
|
| 10–39 | Warnung | „Titellänge unter 40 Zeichen – KI-Ergebnisse können unvollständig sein, da Absender allein bereits 15–20 Zeichen benötigt" |
|
||||||
|
| 40–99 | OK | Normaler Betrieb, keine Meldung |
|
||||||
|
| 100–120 | Warnung | „Hohe Titellänge – Kompatibilität mit verschlüsselten Volumes prüfen" |
|
||||||
|
| > 120 | Fehler | Überschreitet sicheres Limit für verschlüsselte Synology-Volumes, Start wird abgebrochen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GUI – Konfigurationseditor
|
||||||
|
|
||||||
|
- Neues Texteingabefeld im Bereich **„Verarbeitungslimits"**
|
||||||
|
- Beschriftung: **„Max. Titellänge (Zeichen)"**
|
||||||
|
- Validierung erfolgt beim Speichern – ungültige Werte werden **nicht** gespeichert
|
||||||
|
- Warnungen und Fehlermeldungen erscheinen im **Meldungsbereich** (unten in der GUI)
|
||||||
|
- Warnungen blockieren das Speichern **nicht**, Fehler hingegen schon
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verarbeitung / Backend
|
||||||
|
|
||||||
|
- Alle hardcodierten `20`- und `60`-Zeichen-Limits werden durch den konfigurierten Wert ersetzt
|
||||||
|
- **Keine Magic Numbers** mehr im Produktionscode
|
||||||
|
- Der Wert wird beim Start geladen, validiert und an alle betroffenen Komponenten weitergereicht
|
||||||
|
- Betroffen sind mindestens:
|
||||||
|
- `AiResponseValidator`
|
||||||
|
- `TargetFilenameBuildingService`
|
||||||
|
- Prompt-Template (Hinweistext an die KI)
|
||||||
|
- JavaDoc aller betroffenen Klassen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompt-Template
|
||||||
|
|
||||||
|
- Der Hinweis auf die Zeichenbegrenzung im Prompt-Template (`config/prompts/template.txt`)
|
||||||
|
wird ebenfalls dynamisch mit dem konfigurierten Wert befüllt
|
||||||
|
- **Hinweis:** Das Prompt-Template liegt außerhalb des JARs und wird zur Laufzeit gelesen.
|
||||||
|
Die Implementierung muss sicherstellen, dass der konfigurierte Wert zur Laufzeit
|
||||||
|
in den Prompt eingesetzt wird (z. B. per Platzhalter-Ersetzung).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in V2.6 enthalten
|
||||||
|
|
||||||
|
- Automatisches Kürzen von zu langen KI-Titeln
|
||||||
|
- Pfadlängen-Validierung (Gesamtpfad inkl. Ordner)
|
||||||
|
- Unterschiedliche Limits je nach Zielsystem (nur ein globaler Wert)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- [ ] Neuer Parameter ist in der `.properties`-Datei konfigurierbar
|
||||||
|
- [ ] Defaultwert 60 ist abwärtskompatibel (bestehende Configs ohne den Parameter funktionieren)
|
||||||
|
- [ ] Alle Validierungsregeln greifen korrekt (Fehler blockieren Start/Speichern, Warnungen nicht)
|
||||||
|
- [ ] GUI zeigt das neue Feld im richtigen Bereich
|
||||||
|
- [ ] Meldungsbereich zeigt passende Warn- und Fehlertexte
|
||||||
|
- [ ] Keine hardcodierten 20- oder 60-Zeichen-Limits mehr im Produktionscode
|
||||||
|
- [ ] Prompt-Template enthält den konfigurierten Wert zur Laufzeit
|
||||||
|
- [ ] Alle bestehenden Tests werden angepasst
|
||||||
|
- [ ] `mvn clean verify` ist grün
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# V2.7 – GUI-Verarbeitungslauf mit Live-Verfolgung
|
||||||
|
|
||||||
|
**Status:** Freigegeben
|
||||||
|
**Erstellt:** 2026-04-22
|
||||||
|
**Überarbeitet:** 2026-04-22 (nach Review, finale Version)
|
||||||
|
**Autor:** Marcus (mit Claude als Mentor)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
V2.7 erweitert die JavaFX-GUI um einen zweiten Tab „Verarbeitungslauf", über den der Benutzer
|
||||||
|
einen Batch-Lauf direkt aus der GUI starten und dessen Fortschritt in Echtzeit verfolgen kann.
|
||||||
|
Der bestehende headless-Betrieb über den Windows Task Scheduler bleibt unverändert erhalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hintergrund
|
||||||
|
|
||||||
|
### Bisheriger Zustand
|
||||||
|
- Die GUI dient in V2.0–V2.6 ausschließlich der Konfiguration und technischen Validierung
|
||||||
|
- Ein Verarbeitungslauf kann nur über die Kommandozeile bzw. eine Batch-Datei gestartet werden
|
||||||
|
- Es gibt keine Möglichkeit, den Fortschritt eines laufenden Batches live zu beobachten
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
- Der manuelle Kommandozeilenstart ist für den Alltagsbetrieb umständlich
|
||||||
|
- Ohne Live-Anzeige ist unklar, ob und wie schnell die Verarbeitung voranschreitet
|
||||||
|
- Eine einzelne Datei wird schnell verarbeitet – eine Gesamtfortschrittsanzeige ist daher
|
||||||
|
sinnvoller als eine dateiweise Einzelanzeige
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Nach Abschluss von V2.7 kann der Benutzer:
|
||||||
|
|
||||||
|
1. Im neuen Tab „Verarbeitungslauf" einen Batch-Lauf starten
|
||||||
|
2. Den Gesamtfortschritt über alle Dateien live verfolgen
|
||||||
|
3. Jede abgeschlossene Datei mit Ergebnis in einer Liste sehen
|
||||||
|
4. Das KI-Reasoning zu einer Datei per Klick im Seitenbereich einsehen
|
||||||
|
5. Den laufenden Batch per Soft-Stop sauber abbrechen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fachliche Anforderungen
|
||||||
|
|
||||||
|
### Neuer Tab „Verarbeitungslauf"
|
||||||
|
|
||||||
|
- Der bestehende Tab „Konfiguration" bleibt Tab 1 – unverändert
|
||||||
|
- Tab 2 heißt **„Verarbeitungslauf"**
|
||||||
|
- Tab-Struktur war in V2.0 bereits vorbereitet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Layout Tab 2
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ [Fortschrittsbalken] 12 / 47 Dateien │
|
||||||
|
├──────────────────────────────────┬──────────────────────┤
|
||||||
|
│ Ergebnisliste │ Seitenbereich │
|
||||||
|
│ (scrollbar) │ (KI-Reasoning) │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
├──────────────────────────────────┴──────────────────────┤
|
||||||
|
│ Meldungs- und Zusammenfassungsbereich │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ [Starten] [Abbrechen] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Meldungs- und Zusammenfassungsbereich
|
||||||
|
|
||||||
|
Der untere Bereich des Tab 2 dient als **einheitlicher Meldungs- und Zusammenfassungsbereich**.
|
||||||
|
Er übernimmt zwei Rollen:
|
||||||
|
|
||||||
|
- **Meldungsbereich** – zeigt Startfehler, Hinweise (z. B. 0 Dateien) und technische Exceptions
|
||||||
|
- **Zusammenfassung** – zeigt nach Laufende: `{X} erfolgreich, {X} fehlgeschlagen, {X} übersprungen`
|
||||||
|
|
||||||
|
Während des Laufs ist der Bereich leer oder zeigt den letzten Statushinweis.
|
||||||
|
Es gibt in Tab 2 keinen separaten zweiten Meldungsbereich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Konfigurationsquelle beim Start
|
||||||
|
|
||||||
|
- Der Lauf verwendet ausschließlich den **zuletzt gespeicherten Stand** der `.properties`-Datei
|
||||||
|
- Ungespeicherte Änderungen im Konfigurationseditor (Tab 1) fließen **nicht** in den Lauf ein
|
||||||
|
- Der Starten-Button prüft vor dem Lauf, ob die gespeicherte Konfiguration lauffähig ist –
|
||||||
|
nicht den aktuellen Editorzustand
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Startvoraussetzungen und Startfehler
|
||||||
|
|
||||||
|
Ein Lauf startet nur, wenn alle folgenden Voraussetzungen erfüllt sind:
|
||||||
|
|
||||||
|
| Voraussetzung | Verhalten bei Fehler |
|
||||||
|
|---|---|
|
||||||
|
| Gespeicherte Konfiguration vorhanden und lauffähig | Fehlermeldung, kein Lauf |
|
||||||
|
| Quellordner vorhanden und lesbar | Fehlermeldung, kein Lauf |
|
||||||
|
| Zielordner vorhanden oder anlegbar | Fehlermeldung, kein Lauf |
|
||||||
|
| SQLite-Datei nutzbar | Fehlermeldung, kein Lauf |
|
||||||
|
| API-Key vorhanden | Fehlermeldung, kein Lauf |
|
||||||
|
| Kein anderer Verarbeitungslauf in dieser Anwendungsinstanz aktiv | Fehlermeldung, kein Lauf |
|
||||||
|
|
||||||
|
Bei einem Startfehler:
|
||||||
|
- Erscheint eine klare Fehlermeldung im Meldungs- und Zusammenfassungsbereich
|
||||||
|
- Fortschrittsbalken und Ergebnisliste bleiben unverändert
|
||||||
|
- Starten-Button bleibt aktiv, Abbrechen-Button bleibt deaktiviert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verhalten bei 0 verarbeitbaren Dateien
|
||||||
|
|
||||||
|
- Kein technischer Fehler
|
||||||
|
- Kein Lauf im eigentlichen Sinne
|
||||||
|
- Hinweis im Meldungs- und Zusammenfassungsbereich: „Keine verarbeitbaren Dateien im Quellordner gefunden"
|
||||||
|
- Zusammenfassung: `0 erfolgreich, 0 fehlgeschlagen, 0 übersprungen`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fortschrittsbalken
|
||||||
|
|
||||||
|
- Die zu verarbeitende Dateimenge wird **einmalig beim Start** bestimmt
|
||||||
|
- Der Nenner bleibt für den gesamten Lauf **konstant** – Dateien die während des Laufs
|
||||||
|
im Quellordner auftauchen oder verschwinden, werden nicht berücksichtigt
|
||||||
|
- Gezählt werden **alle abgeschlossenen** Dateien: erfolgreich + fehlgeschlagen + übersprungen
|
||||||
|
- Daneben wird der Zählerstand angezeigt, z. B. „12 / 47 Dateien"
|
||||||
|
- Vor dem ersten Start: leer / 0 %
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Statusmodell
|
||||||
|
|
||||||
|
Jede Datei erhält nach Abschluss genau einen der folgenden Status:
|
||||||
|
|
||||||
|
| Status | Icon | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| Erfolgreich | ✅ | Datei wurde umbenannt, Zieldatei erzeugt |
|
||||||
|
| Fehlgeschlagen (retryable) | ⚠️ | Transienter Fehler, wird beim nächsten Lauf erneut versucht |
|
||||||
|
| Fehlgeschlagen (permanent) | ❌ | Inhaltsfehler, kein weiterer Retry |
|
||||||
|
| Übersprungen | ⏭️ | Datei war bereits verarbeitet oder wurde bewusst ausgelassen |
|
||||||
|
|
||||||
|
Alle vier Status zählen als **abgeschlossen** im Sinne des Fortschrittsbalkens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ergebnisliste
|
||||||
|
|
||||||
|
Jede abgeschlossene Datei erscheint als neue Zeile in der Liste.
|
||||||
|
Nach Abschluss jeder Datei erscheint **ohne manuellen Refresh** ein neuer Eintrag.
|
||||||
|
Die Liste wächst während des Laufs von oben nach unten.
|
||||||
|
|
||||||
|
| Spalte | Erfolg | Fehler / Übersprungen |
|
||||||
|
|---|---|---|
|
||||||
|
| Status-Icon | ✅ / ⚠️ / ❌ / ⏭️ | wie links |
|
||||||
|
| Originaldateiname | Quelldateiname | Quelldateiname |
|
||||||
|
| Neuer Dateiname | Finaler Zieldateiname | `—` |
|
||||||
|
| Datum | Ermitteltes Datum | `—` |
|
||||||
|
| Dauer | Verarbeitungszeit in Sekunden | Verarbeitungszeit in Sekunden |
|
||||||
|
|
||||||
|
- Klick auf eine Zeile zeigt Details im **Seitenbereich**
|
||||||
|
- Die Liste ist scrollbar
|
||||||
|
- Die Liste ist **nicht persistent**: bleibt nur für die Dauer des aktuellen Programmstarts
|
||||||
|
- Bei einem neuen Lauf innerhalb desselben Programmstarts wird die Liste geleert
|
||||||
|
- Nach Programmstart ist die Liste leer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Seitenbereich (KI-Reasoning)
|
||||||
|
|
||||||
|
- Rechts neben der Ergebnisliste, fest im Layout verankert (kein Popup, kein Dialog)
|
||||||
|
- Zeigt nach Klick auf eine Zeile:
|
||||||
|
- Originaldateiname
|
||||||
|
- Ermittelter Titel
|
||||||
|
- Ermitteltes Datum
|
||||||
|
- KI-Reasoning (Volltext)
|
||||||
|
- Liegt für einen Eintrag kein KI-Reasoning vor (Fehler vor KI-Aufruf, übersprungen),
|
||||||
|
erscheint der Hinweistext: „Für diesen Eintrag liegt kein KI-Reasoning vor."
|
||||||
|
- Vor dem ersten Klick: Hinweistext „Datei auswählen für Details"
|
||||||
|
- Bei neuem Lauf wird der Seitenbereich geleert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Starten-Button
|
||||||
|
|
||||||
|
- Startet den Verarbeitungslauf über alle Dateien im konfigurierten Quellordner
|
||||||
|
- Verwendet die **gespeicherte** Konfiguration – nicht den aktuellen Editorzustand
|
||||||
|
- Gleiches fachliches Batch-Verhalten wie der headless-Betrieb:
|
||||||
|
gleiche Anwendungslogik, gleicher Use Case, nur andere Präsentationsschicht
|
||||||
|
- Keine Dateiauswahl – alle Dateien werden verarbeitet
|
||||||
|
- Während des Laufs: deaktiviert
|
||||||
|
- Nach Abschluss oder Abbruch: wieder aktiv
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Abbrechen-Button
|
||||||
|
|
||||||
|
- Nur während eines laufenden Batches aktiv, sonst deaktiviert
|
||||||
|
- Verhalten: **Soft-Stop**
|
||||||
|
- Die aktuell in Bearbeitung befindliche Datei wird vollständig fertig verarbeitet
|
||||||
|
- Das Stop-Flag wird nach Abschluss jeder Datei und vor Start der nächsten Datei geprüft –
|
||||||
|
niemals mitten in einer atomaren Persistenzoperation
|
||||||
|
- Danach wird der Lauf sauber beendet, keine halbfertigen Zustände in der SQLite-Datenbank
|
||||||
|
- Nach dem Soft-Stop erscheint die Zusammenfassung im Meldungs- und Zusammenfassungsbereich
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Konfiguration während des Laufs
|
||||||
|
|
||||||
|
- Tab 1 „Konfiguration" wird während eines laufenden Verarbeitungslaufs **gesperrt**
|
||||||
|
- Im Konfiguration-Tab erscheint ein sichtbarer Hinweis:
|
||||||
|
„Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar"
|
||||||
|
- Nach Abschluss, Abbruch oder unerwarteter Exception wird Tab 1 wieder freigegeben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verhalten bei unerwarteter technischer Exception
|
||||||
|
|
||||||
|
Tritt während des Laufs eine unerwartete Exception auf:
|
||||||
|
|
||||||
|
- Die GUI wechselt in einen definierten terminalen Zustand:
|
||||||
|
- Starten-Button: aktiv
|
||||||
|
- Abbrechen-Button: deaktiviert
|
||||||
|
- Tab 1: entsperrt
|
||||||
|
- Meldungs- und Zusammenfassungsbereich: Fehlermeldung sichtbar
|
||||||
|
- Es entsteht kein „hängender" UI-Zustand
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fenster schließen während eines laufenden Laufs
|
||||||
|
|
||||||
|
- Schließt der Benutzer das Fenster während ein Lauf aktiv ist,
|
||||||
|
wird der Close-Request abgefangen
|
||||||
|
- Es erscheint ein Hinweisdialog mit zwei Optionen:
|
||||||
|
- **„Nicht schließen"** – Lauf läuft weiter
|
||||||
|
- **„Lauf beenden und schließen"** – Soft-Stop wird ausgelöst,
|
||||||
|
nach Abschluss der aktuellen Datei schließt die Anwendung
|
||||||
|
- Kein Hard-Abbruch ohne Benutzerentscheidung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Parallele Läufe
|
||||||
|
|
||||||
|
- Pro Anwendungsinstanz ist **nur ein Verarbeitungslauf gleichzeitig** zulässig
|
||||||
|
- Ein zweiter Startversuch während ein Lauf aktiv ist wird verweigert mit der Meldung:
|
||||||
|
„Ein Verarbeitungslauf ist bereits aktiv."
|
||||||
|
- **Bekannte Einschränkung:** Ein gleichzeitiger externer headless-Lauf (Windows Task Scheduler)
|
||||||
|
wird von der GUI nicht aktiv erkannt und nicht technisch geblockt.
|
||||||
|
Der Benutzer ist selbst verantwortlich, parallele Läufe zu vermeiden.
|
||||||
|
Diese Einschränkung ist seit V2.0 dokumentiert und bleibt in V2.7 unverändert bestehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in V2.7 enthalten
|
||||||
|
|
||||||
|
- Dateiauswahl (welche Dateien verarbeitet werden sollen)
|
||||||
|
- Einzeldatei-Fortschrittsanzeige
|
||||||
|
- Historien-Tab / SQLite-Ansicht
|
||||||
|
- Kosten-Tracking
|
||||||
|
- Automatischer Neustart nach Abschluss
|
||||||
|
- Benachrichtigungen (Windows-Tray, Toast)
|
||||||
|
- Parallelverarbeitung mehrerer Dateien
|
||||||
|
- Technisches Locking gegen externe headless-Läufe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- [ ] Tab 2 „Verarbeitungslauf" ist in der GUI vorhanden und erreichbar
|
||||||
|
- [ ] Starten-Button verwendet ausschließlich die gespeicherte Konfiguration
|
||||||
|
- [ ] Starten-Button startet den Batch-Lauf über alle Dateien im Quellordner
|
||||||
|
- [ ] Die Dateimenge wird beim Start einmalig bestimmt; der Nenner des Fortschrittsbalkens bleibt während des gesamten Laufs konstant
|
||||||
|
- [ ] Fortschrittsbalken zählt alle abgeschlossenen Dateien (erfolgreich + fehlgeschlagen + übersprungen)
|
||||||
|
- [ ] Nach Abschluss jeder Datei erscheint ohne manuellen Refresh ein neuer Eintrag in der Ergebnisliste
|
||||||
|
- [ ] Alle fünf Spalten der Ergebnisliste sind für Erfolgsfälle korrekt befüllt
|
||||||
|
- [ ] Spalte „Neuer Dateiname" und „Datum" zeigen `—` für Fehler- und Übersprungen-Fälle
|
||||||
|
- [ ] Alle vier Status-Icons sind korrekt: ✅ ⚠️ ❌ ⏭️
|
||||||
|
- [ ] Klick auf Zeile zeigt KI-Reasoning im Seitenbereich
|
||||||
|
- [ ] Einträge ohne KI-Reasoning zeigen den definierten Hinweistext im Seitenbereich
|
||||||
|
- [ ] Seitenbereich zeigt vor erstem Klick den Hinweistext „Datei auswählen für Details"
|
||||||
|
- [ ] Soft-Stop beendet den Lauf nach Abschluss der aktuellen Datei; keine weitere Datei wird begonnen
|
||||||
|
- [ ] Meldungs- und Zusammenfassungsbereich zeigt nach Laufende die Zusammenfassung mit korrekten Zählern
|
||||||
|
- [ ] Tab 1 ist während des Laufs gesperrt, Hinweis ist sichtbar
|
||||||
|
- [ ] Tab 1 wird nach Abschluss, Abbruch oder Exception wieder entsperrt
|
||||||
|
- [ ] Bei unerwarteter Exception wechselt die GUI in den definierten terminalen Zustand
|
||||||
|
- [ ] Ergebnisliste und Seitenbereich sind nach Programmstart leer
|
||||||
|
- [ ] Ergebnisliste und Seitenbereich werden bei neuem Lauf geleert
|
||||||
|
- [ ] Start mit nicht lauffähiger Konfiguration wird verweigert; Fehlermeldung erscheint im Meldungs- und Zusammenfassungsbereich
|
||||||
|
- [ ] Start bei leerem Quellordner erzeugt keinen Fehler; Hinweis erscheint im Meldungs- und Zusammenfassungsbereich
|
||||||
|
- [ ] Zweiter Startversuch während laufendem Lauf wird verweigert; Meldung erscheint
|
||||||
|
- [ ] Close-Request während Lauf öffnet Hinweisdialog mit zwei Optionen
|
||||||
|
- [ ] headless-Betrieb ist unverändert funktionsfähig
|
||||||
|
- [ ] `mvn clean verify` ist grün
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# V2.8 – Selektive Wiederverarbeitung und Status-Reset in der Ergebnisliste
|
||||||
|
|
||||||
|
**Status:** Freigegeben
|
||||||
|
**Erstellt:** 2026-04-23
|
||||||
|
**Überarbeitet:** 2026-04-23 (nach zwei Reviews, finale Version)
|
||||||
|
**Autor:** Marcus (mit Claude als Mentor)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
V2.8 erweitert den Tab „Verarbeitungslauf" um die Möglichkeit, einzelne oder mehrere Dateien
|
||||||
|
aus der Ergebnisliste gezielt erneut verarbeiten zu lassen oder deren DB-Status zurückzusetzen –
|
||||||
|
ohne die gesamte Datenbank löschen zu müssen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hintergrund
|
||||||
|
|
||||||
|
### Bisheriger Zustand
|
||||||
|
- Nach einem abgeschlossenen Lauf sind alle Ergebnisse in der Ergebnisliste sichtbar
|
||||||
|
- Dateien mit Status `FAILED_FINAL` oder `DONE` können nur durch manuelles Löschen der
|
||||||
|
SQLite-Datenbank erneut verarbeitet werden
|
||||||
|
- Es gibt keine Möglichkeit, einzelne Dateien selektiv zurückzusetzen oder neu zu starten
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
- Nach Anpassung des Prompts oder Wechsel des KI-Modells sollen bereits verarbeitete Dateien
|
||||||
|
erneut verarbeitet werden können – ohne Datenverlust für andere Dokumente
|
||||||
|
- Permanent fehlgeschlagene Dateien sollen nach Behebung der Ursache gezielt neu gestartet
|
||||||
|
werden können
|
||||||
|
- Zwei klar getrennte Aktionen decken unterschiedliche Anwendungsfälle ab:
|
||||||
|
sofortige Wiederverarbeitung vs. Reset für den nächsten regulären Lauf
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Nach Abschluss von V2.8 kann der Benutzer:
|
||||||
|
|
||||||
|
1. Eine oder mehrere Dateien in der Ergebnisliste selektieren
|
||||||
|
2. Per „Erneut verarbeiten" einen sofortigen Mini-Lauf nur für die selektierten Dateien starten
|
||||||
|
3. Per „Status zurücksetzen" den DB-Status zurücksetzen ohne sofortige Verarbeitung –
|
||||||
|
die Dateien werden beim nächsten regulären Lauf automatisch mitgenommen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fachliche Anforderungen
|
||||||
|
|
||||||
|
### Selektion in der Ergebnisliste
|
||||||
|
|
||||||
|
- Es gibt genau **eine fachliche Selektion** je Ergebniszeile
|
||||||
|
- Checkbox, Zeilenklick, Shift/Strg und „Alle auswählen" wirken immer auf **dieselbe Selektionsmenge**
|
||||||
|
- Jede Zeile erhält eine **Checkbox** am linken Rand
|
||||||
|
- **Shift/Strg-Mehrfachselektion** wie im Windows Explorer ist möglich
|
||||||
|
- Eine Checkbox **„Alle auswählen"** oberhalb der Liste selektiert/deselektiert alle Einträge
|
||||||
|
- Alle Status sind selektierbar: ✔ erfolgreich, ⚠ retryable, ✘ permanent, ► übersprungen
|
||||||
|
- Die Selektion bleibt erhalten bis ein neuer Lauf gestartet wird
|
||||||
|
- Während eines laufenden Mini-Laufs ist die Selektion **gesperrt** –
|
||||||
|
Änderungen der Selektion nach Laufstart haben keinen Einfluss auf den laufenden Batch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Button „Erneut verarbeiten"
|
||||||
|
|
||||||
|
- **Aktion:** DB-Status der selektierten Dateien zurücksetzen + sofortiger Mini-Lauf
|
||||||
|
nur für diese Dateien
|
||||||
|
- **Aktiv wenn:** Kein Lauf aktiv UND mindestens 1 Eintrag selektiert
|
||||||
|
- **Inaktiv wenn:** Lauf läuft ODER keine Selektion
|
||||||
|
- **Verhalten:**
|
||||||
|
- Der Mini-Lauf arbeitet auf einem **Snapshot** der beim Klick selektierten Einträge
|
||||||
|
- DB-Status aller selektierten Einträge wird zurückgesetzt
|
||||||
|
- Sofort danach startet ein Mini-Lauf ausschließlich für diese Dateien
|
||||||
|
- Die Ergebnisliste wird für die selektierten Einträge live aktualisiert
|
||||||
|
- Nicht selektierte Einträge bleiben unverändert in der Liste
|
||||||
|
- Der Mini-Lauf verhält sich fachlich wie ein regulärer Lauf –
|
||||||
|
gleiche Anwendungslogik, gleicher Use Case, nur eingeschränkte Dateimenge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Button „Status zurücksetzen"
|
||||||
|
|
||||||
|
- **Aktion:** Nur DB-Status der selektierten Dateien zurücksetzen, keine sofortige Verarbeitung
|
||||||
|
- **Aktiv wenn:** Kein Lauf aktiv UND mindestens 1 Eintrag selektiert
|
||||||
|
- **Inaktiv wenn:** Lauf läuft ODER keine Selektion
|
||||||
|
- **Verhalten:**
|
||||||
|
- DB-Status aller selektierten Einträge wird zurückgesetzt
|
||||||
|
- Kein sofortiger Lauf
|
||||||
|
- Betroffene Zeilen bleiben in der Ergebnisliste sichtbar und erhalten die
|
||||||
|
Kennzeichnung **„Zurückgesetzt – wartet auf nächsten Lauf"**
|
||||||
|
- Beim nächsten regulären Lauf werden die zurückgesetzten Dateien automatisch mitgenommen
|
||||||
|
- **Fehlerbehandlung:** Reset läuft nach **Best-effort**-Prinzip –
|
||||||
|
erfolgreich zurückgesetzte Einträge werden zurückgesetzt, fehlgeschlagene bleiben
|
||||||
|
im alten Status; der Meldungs- und Zusammenfassungsbereich zeigt:
|
||||||
|
- Anzahl ausgewählter Einträge
|
||||||
|
- Anzahl erfolgreich zurückgesetzt
|
||||||
|
- Anzahl fehlgeschlagen
|
||||||
|
- Bei Fehlern: betroffene Dateinamen im Meldungsbereich
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Welche Status können zurückgesetzt werden
|
||||||
|
|
||||||
|
Alle Status sind zurücksetzbar:
|
||||||
|
|
||||||
|
| UI-Status | DB-Status | Zurücksetzbar | Verhalten im nächsten regulären Lauf |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ✔ Erfolgreich | `DONE` | Ja | Wird erneut verarbeitet |
|
||||||
|
| ⚠ Fehlgeschlagen retryable | `FAILED_RETRYABLE` | Ja | Wird erneut verarbeitet |
|
||||||
|
| ✘ Fehlgeschlagen permanent | `FAILED_FINAL` | Ja | Wird erneut verarbeitet |
|
||||||
|
| ► Übersprungen | `DONE` | Ja | DB-Eintrag `DONE` wird zurückgesetzt, wird erneut verarbeitet |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verhalten bei vorhandener Zieldatei (Re-Run von DONE)
|
||||||
|
|
||||||
|
Wird eine bereits erfolgreich verarbeitete Datei erneut verarbeitet:
|
||||||
|
|
||||||
|
- **KI schlägt identischen Zieldateinamen vor** und Zieldatei ist bereits vorhanden:
|
||||||
|
Datei gilt als **✔ erfolgreich** – kein neuer Eintrag im Zielordner, kein Fehler
|
||||||
|
- **KI schlägt anderen Namen vor:** Normale Verarbeitung –
|
||||||
|
Dubletten-Suffix `(1)`, `(2)` wie im regulären Betrieb wenn nötig
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verhalten bei fehlender oder verschobener Quelldatei
|
||||||
|
|
||||||
|
Ist die Quelldatei zum Zeitpunkt des Mini-Laufs nicht mehr vorhanden:
|
||||||
|
|
||||||
|
- Eintrag erhält Status **✘ permanent fehlgeschlagen**
|
||||||
|
- Meldung: „Quelldatei nicht gefunden: {Dateiname}"
|
||||||
|
- Kein weiterer Retry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Verhalten während eines Mini-Laufs
|
||||||
|
|
||||||
|
- Der **Abbrechen-Button** (Soft-Stop aus V2.7) gilt auch für den Mini-Lauf
|
||||||
|
- Bei Soft-Stop:
|
||||||
|
- Bereits erfolgreich verarbeitete Einträge behalten ihren neuen Endstatus
|
||||||
|
- Noch nicht gestartete, aber bereits zurückgesetzte Einträge behalten den Status
|
||||||
|
„Zurückgesetzt – wartet auf nächsten Lauf" und werden beim nächsten regulären Lauf mitgenommen
|
||||||
|
- Der Mini-Lauf endet im UI-Zustand „abgebrochen" mit Zusammenfassung
|
||||||
|
- Tab 1 „Konfiguration" wird während des Mini-Laufs gesperrt
|
||||||
|
- Fortschrittsbalken zeigt den Fortschritt des Mini-Laufs –
|
||||||
|
Nenner entspricht der Anzahl der selektierten Dateien
|
||||||
|
- Während eines Mini-Laufs sind „Erneut verarbeiten" und „Status zurücksetzen" deaktiviert
|
||||||
|
- Kein zweiter paralleler Lauf ist startbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scope dieser Funktion
|
||||||
|
|
||||||
|
Die Funktion gilt ausschließlich für Einträge der **sichtbaren Ergebnisliste der aktuellen Sitzung**.
|
||||||
|
Beim Programmstart erfolgt keine Rekonstruktion der Ergebnisliste aus der DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in V2.8 enthalten
|
||||||
|
|
||||||
|
- Historien-Tab / SQLite-Ansicht (V3.0)
|
||||||
|
- Bearbeitung des KI-Titels in der GUI
|
||||||
|
- Manuelles Überschreiben eines Ergebnisses
|
||||||
|
- Massenoperationen außerhalb der Ergebnisliste
|
||||||
|
- Automatischer Re-Run nach Konfigurationsänderung
|
||||||
|
- Rekonstruktion der Ergebnisliste beim Programmstart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
- [ ] Jede Zeile der Ergebnisliste hat eine Checkbox
|
||||||
|
- [ ] Checkbox und Zeilenklick repräsentieren dieselbe Selektionsmenge
|
||||||
|
- [ ] Shift/Strg-Mehrfachselektion funktioniert wie im Windows Explorer
|
||||||
|
- [ ] „Alle auswählen"-Checkbox selektiert/deselektiert alle Einträge
|
||||||
|
- [ ] Alle vier Status sind selektierbar
|
||||||
|
- [ ] Während eines laufenden Mini-Laufs kann die Selektion nicht verändert werden
|
||||||
|
- [ ] Button „Erneut verarbeiten" ist nur aktiv wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||||
|
- [ ] Button „Status zurücksetzen" ist nur aktiv wenn kein Lauf läuft und mindestens 1 Eintrag selektiert ist
|
||||||
|
- [ ] „Erneut verarbeiten" setzt DB-Status zurück und startet sofortigen Mini-Lauf nur für selektierte Dateien
|
||||||
|
- [ ] Der Mini-Lauf verarbeitet genau die beim Start selektierten Einträge – spätere Selektionsänderungen haben keinen Einfluss
|
||||||
|
- [ ] „Status zurücksetzen" setzt nur den DB-Status zurück, betroffene Zeilen erhalten Kennzeichnung „Zurückgesetzt – wartet auf nächsten Lauf"
|
||||||
|
- [ ] Reset-Ergebnis zeigt Anzahl ausgewählter, erfolgreich zurückgesetzter und fehlgeschlagener Einträge
|
||||||
|
- [ ] Bei identischem Zieldateinamen gilt der Eintrag nach Re-Run als ✔ erfolgreich
|
||||||
|
- [ ] Fehlende Quelldatei führt zu ✘ permanent fehlgeschlagen mit Meldung
|
||||||
|
- [ ] Mini-Lauf zeigt korrekten Fortschrittsbalken für die selektierte Dateimenge
|
||||||
|
- [ ] Abbrechen-Button (Soft-Stop) funktioniert auch während eines Mini-Laufs
|
||||||
|
- [ ] Nach Soft-Stop: bereits verarbeitete Einträge behalten neuen Status, nicht gestartete bleiben „Zurückgesetzt"
|
||||||
|
- [ ] Tab 1 ist während des Mini-Laufs gesperrt
|
||||||
|
- [ ] Nicht selektierte Einträge bleiben nach „Erneut verarbeiten" unverändert in der Liste
|
||||||
|
- [ ] Beim nächsten regulären Lauf nach „Status zurücksetzen" werden zurückgesetzte Dateien mitgenommen
|
||||||
|
- [ ] Während eines Mini-Laufs sind beide Buttons deaktiviert
|
||||||
|
- [ ] headless-Betrieb ist unverändert funktionsfähig
|
||||||
|
- [ ] `mvn clean verify` ist grün
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
# V2.9 – Integrierte PDF-Vorschau und Dateinamen-Bearbeitung
|
||||||
|
|
||||||
|
**Status:** Freigegeben
|
||||||
|
**Erstellt:** 2026-04-24
|
||||||
|
**Überarbeitet:** 2026-04-24 (nach zwei ChatGPT-Reviews, finale Version)
|
||||||
|
**Autor:** Marcus (mit Claude als Mentor)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
V2.9 erweitert den Tab „Verarbeitungslauf" um zwei eng verzahnte Funktionen:
|
||||||
|
|
||||||
|
1. **Integrierte PDF-Vorschau** – beim Anklicken einer Zeile wird die erste Seite der
|
||||||
|
Quelldatei direkt im Detailbereich rechts gerendert (kein separates Fenster, kein
|
||||||
|
zusätzlicher Klick)
|
||||||
|
2. **Editierbarer Dateiname** – der von der KI vorgeschlagene Dateiname kann direkt
|
||||||
|
in der GUI korrigiert werden, bevor er als endgültig gilt
|
||||||
|
|
||||||
|
Beide Funktionen zusammen ermöglichen einen natürlichen Review-Zyklus:
|
||||||
|
**KI benennt → Benutzer schaut rein → Benutzer korrigiert bei Bedarf → fertig.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hintergrund
|
||||||
|
|
||||||
|
### Bisheriger Zustand
|
||||||
|
|
||||||
|
- Der Detailbereich rechts zeigt nur KI-Begründung als TextArea
|
||||||
|
- Ob der vorgeschlagene Dateiname sinnvoll ist, kann der Benutzer nur anhand des
|
||||||
|
KI-Reasonings beurteilen – den tatsächlichen Dokumentinhalt sieht er nicht
|
||||||
|
- Der generierte Dateiname ist nach dem Lauf nicht mehr veränderbar
|
||||||
|
- Die Spike-Implementierung (PDFViewFX + jai-imageio-jpeg2000 für JBIG2-Unterstützung)
|
||||||
|
hat die technische Machbarkeit bereits bestätigt; der Spike-Code wird im Rahmen
|
||||||
|
von V2.9 durch produktionsreifen Code ersetzt
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
|
||||||
|
- Benutzer sollen schnell beurteilen können, ob der KI-Dateiname passt,
|
||||||
|
ohne ein externes Programm öffnen zu müssen
|
||||||
|
- Korrekturen sollen direkt in der Anwendung möglich sein – für nicht-technische
|
||||||
|
Benutzer (z. B. Familienmitglieder) ist das eine wesentliche UX-Verbesserung
|
||||||
|
- Die Anwendung wird vom reinen Batch-Prozessor zum assistierten
|
||||||
|
Dokumenten-Review-Werkzeug weiterentwickelt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
Nach Abschluss von V2.9 kann der Benutzer:
|
||||||
|
|
||||||
|
1. Eine Zeile in der Ergebnisliste anklicken
|
||||||
|
2. Sofort die erste Seite der zugehörigen **Quelldatei** als Vorschau sehen –
|
||||||
|
ohne weiteren Klick, direkt im Detailbereich
|
||||||
|
3. Weitere Seiten bei Bedarf **auf Anfrage** laden (Lazy Rendering)
|
||||||
|
4. Den vorgeschlagenen Dateinamen **direkt in der GUI bearbeiten** und speichern
|
||||||
|
5. Den headless-Betrieb unverändert nutzen – V2.9 betrifft ausschließlich die GUI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout-Änderung im Tab „Verarbeitungslauf"
|
||||||
|
|
||||||
|
### Bisheriges Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Fortschrittsbalken ]
|
||||||
|
[ Ergebnistabelle (~75% Breite) | KI-Begründung (~25%) ]
|
||||||
|
[ Buttons ]
|
||||||
|
[ Statuszeile ]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neues Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Fortschrittsbalken ]
|
||||||
|
[ Ergebnistabelle (~60% Breite) | Detailbereich (~40% Breite) ]
|
||||||
|
[ | KI-Begründung ]
|
||||||
|
[ | Dateiname (editierbar) ]
|
||||||
|
[ | PDF-Vorschau (Seite X/Y) ]
|
||||||
|
[ Buttons ]
|
||||||
|
[ Statuszeile ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Tabelle und Detailbereich sind durch einen **verschiebbaren Splitter** (SplitPane)
|
||||||
|
getrennt – der Benutzer kann das Verhältnis anpassen
|
||||||
|
- Standard-Split: 60% Tabelle / 40% Detailbereich
|
||||||
|
- Der Detailbereich ist vertikal aufgebaut: KI-Begründung oben (kompakt),
|
||||||
|
darunter Dateiname-Feld, darunter PDF-Vorschau (nimmt verfügbaren Restplatz)
|
||||||
|
- Die PDF-Vorschau rendert die erste Seite **„fit to width"** – Seitenverhältnis wird
|
||||||
|
beibehalten, die Seite füllt die verfügbare Panelbreite aus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fachliche Anforderungen
|
||||||
|
|
||||||
|
### PDF-Vorschau
|
||||||
|
|
||||||
|
#### Grundverhalten
|
||||||
|
|
||||||
|
- Beim Anklicken einer Zeile in der Ergebnisliste wird **automatisch** Seite 1 der
|
||||||
|
zugehörigen **Quelldatei** gerendert und im Vorschaubereich angezeigt
|
||||||
|
- Das Rendering erfolgt **asynchron im Hintergrund** – die GUI bleibt während des
|
||||||
|
Ladens reaktionsfähig
|
||||||
|
- Während des Renderings wird ein **Ladeindikator** (z. B. ProgressIndicator) angezeigt
|
||||||
|
- Die Vorschau zeigt immer die **Quelldatei**, nicht die umbenannte Zieldatei
|
||||||
|
|
||||||
|
#### Lazy Rendering und Seitennavigation
|
||||||
|
|
||||||
|
- Beim ersten Anklicken einer Zeile wird **ausschließlich Seite 1** gerendert
|
||||||
|
- Unterhalb der Vorschau wird die aktuelle Seite sowie die Gesamtseitenzahl
|
||||||
|
angezeigt: „Seite 1 / 12"
|
||||||
|
- Navigation:
|
||||||
|
- Button **„Nächste Seite"** lädt und rendert die jeweils nächste Seite on-demand
|
||||||
|
- Button **„Vorherige Seite"** lädt die vorherige Seite
|
||||||
|
- Bereits gerenderte Seiten werden **gecacht** – ein erneuter Wechsel auf eine
|
||||||
|
bereits gerenderte Seite erfordert kein erneutes Rendering
|
||||||
|
- Der Cache wird geleert wenn eine andere Zeile angeklickt wird
|
||||||
|
- Die Navigations-Buttons sind bei Seite 1 (Zurück) bzw. letzter Seite (Weiter)
|
||||||
|
deaktiviert
|
||||||
|
|
||||||
|
#### Abbruchverhalten bei schnellem Wechsel (Latest Preview Request Wins)
|
||||||
|
|
||||||
|
- Es gilt das Prinzip **„latest preview request wins"**: Wenn während eines laufenden
|
||||||
|
Renderings eine neue Vorschau-Anforderung eingeht – sei es durch Selektionswechsel
|
||||||
|
oder durch Seitennavigation innerhalb derselben PDF – wird das laufende Rendering
|
||||||
|
abgebrochen bzw. sein Ergebnis verworfen
|
||||||
|
- Nur das Ergebnis der zuletzt angeforderten Vorschau darf im Vorschaubereich landen
|
||||||
|
- Veraltete Render-Ergebnisse werden niemals angezeigt
|
||||||
|
|
||||||
|
#### Fehlerfälle PDF-Vorschau
|
||||||
|
|
||||||
|
| Situation | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| Quelldatei nicht mehr vorhanden | Meldung im Vorschaubereich: „Quelldatei nicht gefunden" |
|
||||||
|
| PDF nicht lesbar / korrupt | Meldung im Vorschaubereich: „PDF konnte nicht geöffnet werden" |
|
||||||
|
| PDF passwortgeschützt / verschlüsselt | Meldung im Vorschaubereich: „PDF ist passwortgeschützt und kann nicht angezeigt werden" |
|
||||||
|
| JBIG2-Bilder nicht vollständig dekodierbar | Seite wird teilweise gerendert; kein Fehler-Abbruch; kein Hinweis nötig |
|
||||||
|
| Kein Eintrag selektiert | Vorschaubereich zeigt neutralen Platzhaltertext |
|
||||||
|
|
||||||
|
#### Technische Grundlage
|
||||||
|
|
||||||
|
- Bibliothek: `com.dlsc.pdfviewfx:pdfviewfx` (bereits im Spike erfolgreich getestet)
|
||||||
|
- Zusatzabhängigkeit für JBIG2 und erweiterte Bildformate:
|
||||||
|
`com.github.jai-imageio:jai-imageio-jpeg2000` (bereits im Spike ergänzt)
|
||||||
|
- Der Spike-Code (`PdfViewerSpike.java`, Spike-Button in `GuiBatchRunTab`) wird
|
||||||
|
vollständig entfernt und durch die produktive Implementierung ersetzt
|
||||||
|
- Rendering läuft in einem dedizierten Background-Thread (nicht im JavaFX
|
||||||
|
Application Thread)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Editierbarer Dateiname
|
||||||
|
|
||||||
|
#### Zustandsmodell
|
||||||
|
|
||||||
|
Der Dateiname-Bereich kennt drei klar getrennte Zustände:
|
||||||
|
|
||||||
|
| Zustand | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **KI-Vorschlag** | Der von der KI ursprünglich generierte Name – unveränderlich in der DB gespeichert; dient als Referenz für „Zurücksetzen auf KI-Vorschlag" |
|
||||||
|
| **Letzter gespeicherter Name** | Der zuletzt per „Dateiname übernehmen" bestätigte Name (= aktueller FS- und DB-Stand); ist nach dem Batch-Lauf zunächst identisch mit dem KI-Vorschlag |
|
||||||
|
| **Aktuelle Eingabe** | Der aktuell im Textfeld eingetippte, noch nicht gespeicherte Wert |
|
||||||
|
|
||||||
|
**Anzeige-Regel:** Im Textfeld wird beim Selektieren einer Zeile immer der
|
||||||
|
**letzte gespeicherte Name** angezeigt – nicht der KI-Vorschlag.
|
||||||
|
Wurde noch nie manuell gespeichert, sind beide identisch.
|
||||||
|
|
||||||
|
**Dirty-State-Regel:** Dirty-State besteht wenn die **aktuelle Eingabe** vom
|
||||||
|
**letzten gespeicherten Namen** abweicht. Der KI-Vorschlag ist keine Dirty-Basis.
|
||||||
|
|
||||||
|
#### Anzeige
|
||||||
|
|
||||||
|
- Unterhalb der KI-Begründung und oberhalb der PDF-Vorschau befindet sich ein
|
||||||
|
Bereich „Dateiname"
|
||||||
|
- Der Dateiname wird in einem **editierbaren Textfeld** (TextField) angezeigt
|
||||||
|
- Das Textfeld zeigt den **letzten gespeicherten Namen** ohne Dateierweiterung
|
||||||
|
(`.pdf` wird separat als nicht editierbares Label daneben angezeigt)
|
||||||
|
- Solange kein Eintrag selektiert ist, ist das Textfeld leer und deaktiviert
|
||||||
|
- Wenn das Textfeld vom letzten gespeicherten Namen abweicht (**Dirty State**),
|
||||||
|
wird dies durch eine visuelle Markierung am Textfeld angezeigt (z. B. farbiger Rand)
|
||||||
|
|
||||||
|
#### Tastatur- und Schaltflächen-Verhalten
|
||||||
|
|
||||||
|
| Aktion | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| **Enter** im Textfeld | Löst „Dateiname übernehmen" aus (sofern Validierung grün) |
|
||||||
|
| **Escape** im Textfeld | Verwirft aktuelle Eingabe; stellt **letzten gespeicherten Namen** wieder her |
|
||||||
|
| **„Dateiname übernehmen"** | Startet die atomare Speicher-Transaktion |
|
||||||
|
| **„Zurücksetzen auf KI-Vorschlag"** | Setzt das Textfeld auf den ursprünglichen KI-Vorschlag zurück (kein Speichern – nur Textfeld-Inhalt) |
|
||||||
|
|
||||||
|
Hinweis: „Zurücksetzen auf KI-Vorschlag" und Escape haben **unterschiedliche Semantik**:
|
||||||
|
Escape = zurück zum letzten gespeicherten Stand; „Zurücksetzen" = zurück zum KI-Ursprung.
|
||||||
|
|
||||||
|
#### Speichern-Transaktion (Alles oder Nichts)
|
||||||
|
|
||||||
|
Das Speichern eines geänderten Dateinamens ist eine **atomare Operation** bestehend
|
||||||
|
aus zwei Persistenzschritten:
|
||||||
|
|
||||||
|
1. Zieldatei im Dateisystem umbenennen
|
||||||
|
2. Eintrag in der SQLite-DB aktualisieren
|
||||||
|
|
||||||
|
**Schlägt Schritt 1 oder 2 fehl, wird die gesamte Aktion abgebrochen:**
|
||||||
|
- Bereits durchgeführte Teilschritte werden zurückgerollt
|
||||||
|
- Dateisystem und DB bleiben im vorherigen Zustand
|
||||||
|
- Eine Fehlermeldung im Statusbereich informiert den Benutzer
|
||||||
|
- Das Textfeld behält den eingegebenen Wert – der Benutzer kann es erneut versuchen
|
||||||
|
|
||||||
|
Nach erfolgreicher Transaktion (Projektionsschritt, nicht Teil der Transaktion):
|
||||||
|
- Tabellenspalte „Neuer Dateiname" wird aktualisiert
|
||||||
|
- Erfolgsmeldung im Statusbereich
|
||||||
|
|
||||||
|
Mögliche Fehlerursachen für Schritt 1: Datei-Lock durch andere Prozesse (Scanner, AV),
|
||||||
|
fehlende Schreibrechte, Read-only-Dateisystem, Netzlaufwerk nicht erreichbar.
|
||||||
|
|
||||||
|
#### Konfliktsemantik bei vorhandenem Zieldateinamen
|
||||||
|
|
||||||
|
Existiert im Zielordner bereits eine Datei mit dem neu eingegebenen Namen,
|
||||||
|
wird anhand des **Fingerprints** (SHA-256 des Dateiinhalts) entschieden:
|
||||||
|
|
||||||
|
| Situation | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| **Gleicher Fingerprint** | Dateien sind inhaltlich identisch → keine Aktion; Meldung im Statusbereich: „Identische Datei bereits vorhanden – keine Umbenennung nötig"; weder FS noch DB werden geändert |
|
||||||
|
| **Unterschiedlicher Fingerprint** | Warnung im Statusbereich; Dateiname im FS erhält automatisch ein Suffix `(1)`, `(2)` usw.; DB wird mit dem tatsächlichen neuen Namen inkl. Suffix aktualisiert |
|
||||||
|
|
||||||
|
#### Validierung des Dateinamens
|
||||||
|
|
||||||
|
Folgende Prüfungen erfolgen **live während der Eingabe**:
|
||||||
|
|
||||||
|
| Prüfung | Verhalten bei Verletzung |
|
||||||
|
|---|---|
|
||||||
|
| Dateiname ist leer oder nur Leerzeichen | Speichern-Button deaktiviert, Hinweistext unterhalb des Feldes |
|
||||||
|
| Führende oder abschließende Leerzeichen | Speichern-Button deaktiviert, Hinweistext |
|
||||||
|
| Unerlaubte Zeichen (`\ / : * ? " < > \|`) | Speichern-Button deaktiviert, Hinweistext |
|
||||||
|
| Reservierte Windows-Namen (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`) | Speichern-Button deaktiviert, Hinweistext |
|
||||||
|
| Dateiname endet auf Punkt | Speichern-Button deaktiviert, Hinweistext |
|
||||||
|
| Dateiname + Zielpfad + `.pdf` überschreitet 259 Zeichen | Speichern-Button deaktiviert, Hinweistext |
|
||||||
|
|
||||||
|
Die 259-Zeichen-Grenze ist eine **bewusste Produktregel** für maximale
|
||||||
|
Windows-Kompatibilität (Windows MAX_PATH = 260 Zeichen inkl. Null-Terminator).
|
||||||
|
|
||||||
|
#### Zustände des Dateiname-Bereichs
|
||||||
|
|
||||||
|
| Zeilenstatus | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| Kein Eintrag selektiert | Textfeld leer, deaktiviert |
|
||||||
|
| Eintrag mit Status `DONE` (erfolgreich) | Textfeld editierbar, letzter gespeicherter Name vorausgefüllt |
|
||||||
|
| Eintrag mit Status `FAILED_*` | Textfeld leer, deaktiviert (kein Dateiname vorhanden) |
|
||||||
|
| Eintrag mit Status `SKIPPED` | Textfeld deaktiviert |
|
||||||
|
| Lauf aktiv | Textfeld deaktiviert, alle Buttons deaktiviert |
|
||||||
|
|
||||||
|
#### Verhalten bei fehlender Zieldatei
|
||||||
|
|
||||||
|
Ist die Zieldatei zum Zeitpunkt des Speicherns nicht mehr im Zielordner vorhanden:
|
||||||
|
|
||||||
|
- Schritt 1 der Transaktion schlägt fehl
|
||||||
|
- Gemäß Alles-oder-Nichts-Prinzip: DB wird **nicht** aktualisiert
|
||||||
|
- Fehlermeldung im Statusbereich: „Zieldatei nicht gefunden – Umbenennung nicht möglich"
|
||||||
|
- Das Textfeld behält den eingegebenen Wert
|
||||||
|
|
||||||
|
#### Verhalten bei ungespeicherten Änderungen (Dirty State)
|
||||||
|
|
||||||
|
Ein Hinweisdialog erscheint, wenn der Benutzer mit aktivem Dirty-State eine der
|
||||||
|
folgenden Aktionen ausführt:
|
||||||
|
|
||||||
|
- Eine andere Zeile in der Ergebnistabelle anklicken
|
||||||
|
- Den Tab wechseln (Konfiguration ↔ Verarbeitungslauf)
|
||||||
|
- Die Anwendung schließen
|
||||||
|
- Einen neuen Lauf starten
|
||||||
|
|
||||||
|
Dialog-Text: „Der Dateiname wurde geändert aber nicht gespeichert. Änderungen verwerfen?"
|
||||||
|
Optionen: **„Verwerfen"** (Dirty State wird geleert, Aktion wird fortgesetzt) /
|
||||||
|
**„Zurück"** (Dialog schließt, Benutzer bleibt im Textfeld)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
### Manuelle Namenskorrektur als Application-Use-Case
|
||||||
|
|
||||||
|
Die manuelle Dateinamen-Korrektur wird als **eigenständiger Application-Use-Case**
|
||||||
|
modelliert, nicht im GUI-Adapter implementiert:
|
||||||
|
|
||||||
|
- Ein neuer Use-Case `ManualFileRenameUseCase` (o. ä.) kapselt die atomare Transaktion
|
||||||
|
aus FS-Rename + DB-Update
|
||||||
|
- Der `GuiBatchRunCoordinator` (GUI-Adapter) delegiert ausschließlich an diesen Use-Case
|
||||||
|
- Dateisystem- und DB-Zugriffe laufen ausschließlich über bestehende oder neue
|
||||||
|
Ports/Adapter – kein Direktzugriff aus dem GUI-Adapter
|
||||||
|
- Damit bleibt die hexagonale Architektur gewahrt und der Use-Case ist unabhängig
|
||||||
|
von der GUI testbar
|
||||||
|
|
||||||
|
### Komponenten-Übersicht
|
||||||
|
|
||||||
|
| Komponente | Änderung |
|
||||||
|
|---|---|
|
||||||
|
| `GuiBatchRunTab` | Hauptumbau: SplitPane, Detailbereich-Redesign, Spike-Code entfernen |
|
||||||
|
| `GuiBatchRunResultRow` | Neues Feld: `correctedFileName` als `Optional<String>` |
|
||||||
|
| `GuiBatchRunCoordinator` | Delegiert Dateinamen-Korrektur an neuen Use-Case |
|
||||||
|
| `ManualFileRenameUseCase` | Neuer Application-Use-Case: atomares FS-Rename + DB-Update |
|
||||||
|
| `pom.xml` (GUI-Modul) | PDFViewFX + jai-imageio-jpeg2000 bleiben; Spike-Klasse entfernen |
|
||||||
|
| Domain / Ports | Ggf. neuer Port für Datei-Rename-Operation erforderlich |
|
||||||
|
| Headless-Betrieb | Unberührt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abhängigkeiten zwischen den Funktionen
|
||||||
|
|
||||||
|
- PDF-Vorschau und editierbarer Dateiname sind **unabhängig voneinander nutzbar**
|
||||||
|
- Beide beziehen sich auf den in der Ergebnistabelle selektierten Eintrag
|
||||||
|
- Beim Selektionswechsel mit Dirty-State: Hinweisdialog erscheint (siehe oben)
|
||||||
|
- PDF-Vorschau-Cache wird beim Selektionswechsel geleert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verhalten während eines laufenden Batch-Laufs
|
||||||
|
|
||||||
|
- Der Detailbereich (PDF-Vorschau + Dateinamen-Editor) ist **vollständig deaktiviert**
|
||||||
|
während ein regulärer Lauf oder Mini-Lauf aktiv ist
|
||||||
|
- Bereits angezeigte Vorschau bleibt sichtbar, aber Navigation und Bearbeitung
|
||||||
|
sind gesperrt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht in V2.9 enthalten
|
||||||
|
|
||||||
|
- Löschen der Quelldatei nach Bestätigung (spätere Version)
|
||||||
|
- Vollständiger PDF-Viewer mit freiem Scrollen und Zoom (Issue #23: DPI-Optimierung)
|
||||||
|
- Historien-Tab / SQLite-Ansicht (Issue #7, V3.0)
|
||||||
|
- Automatischer Scheduler / System-Tray (Issues #20, #22)
|
||||||
|
- Kompakteres Layout der Konfigurationsseite (Issue #24)
|
||||||
|
- Anwendungs-Icon (Issue #21)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahmekriterien
|
||||||
|
|
||||||
|
### Fachliche Akzeptanz
|
||||||
|
|
||||||
|
#### PDF-Vorschau
|
||||||
|
- [ ] Beim Anklicken einer Zeile wird Seite 1 der Quelldatei automatisch gerendert – ohne extra Klick
|
||||||
|
- [ ] Während des Renderings ist ein Ladeindikator sichtbar; die GUI bleibt reaktionsfähig
|
||||||
|
- [ ] Die Vorschau rendert „fit to width" mit beibehaltenem Seitenverhältnis
|
||||||
|
- [ ] Seitenanzahl wird angezeigt: „Seite 1 / X"
|
||||||
|
- [ ] „Nächste Seite" / „Vorherige Seite" laden Seiten on-demand
|
||||||
|
- [ ] Bereits gerenderte Seiten werden gecacht; Selektionswechsel leert den Cache
|
||||||
|
- [ ] Navigations-Buttons sind korrekt deaktiviert (erste / letzte Seite)
|
||||||
|
- [ ] Schneller Selektionswechsel oder Seitenwechsel während Rendering: nur das zuletzt angeforderte Ergebnis wird angezeigt (latest preview request wins)
|
||||||
|
- [ ] Quelldatei nicht vorhanden → verständliche Fehlermeldung im Vorschaubereich
|
||||||
|
- [ ] PDF nicht lesbar / korrupt → verständliche Fehlermeldung im Vorschaubereich
|
||||||
|
- [ ] PDF passwortgeschützt → verständliche Fehlermeldung im Vorschaubereich
|
||||||
|
|
||||||
|
#### Dateiname-Editor
|
||||||
|
- [ ] Textfeld zeigt beim Selektieren den **letzten gespeicherten Namen** (nicht KI-Vorschlag) ohne `.pdf`-Erweiterung; `.pdf` als nicht editierbares Label daneben sichtbar
|
||||||
|
- [ ] Dateiname ist direkt im Textfeld editierbar
|
||||||
|
- [ ] Dirty-State (Abweichung von letztem gespeichertem Namen) wird visuell am Textfeld angezeigt
|
||||||
|
- [ ] Enter im Textfeld löst „Dateiname übernehmen" aus (wenn Validierung grün)
|
||||||
|
- [ ] Escape im Textfeld stellt den **letzten gespeicherten Namen** wieder her
|
||||||
|
- [ ] „Zurücksetzen auf KI-Vorschlag" setzt das Textfeld auf den KI-Ursprung zurück (ohne Speichern)
|
||||||
|
- [ ] Validierung prüft live: leer/nur Leerzeichen, führende/abschließende Leerzeichen, unerlaubte Zeichen, reservierte Windows-Namen, endet auf Punkt, Pfadlänge > 259
|
||||||
|
- [ ] Bei Validierungsfehler: Speichern-Button deaktiviert, Hinweistext sichtbar
|
||||||
|
- [ ] „Dateiname übernehmen" ist atomar: FS und DB werden beide aktualisiert oder nichts davon
|
||||||
|
- [ ] Bei Fehler in FS oder DB: kein Teilupdate, Rollback, Fehlermeldung im Statusbereich, Textfeld behält Eingabe
|
||||||
|
- [ ] Nach Erfolg: Tabellenspalte und Statusbereich aktualisiert (Projektionsschritt)
|
||||||
|
- [ ] Dateikonflikt mit gleichem Fingerprint → keine Aktion, Meldung „Identische Datei bereits vorhanden"
|
||||||
|
- [ ] Dateikonflikt mit unterschiedlichem Fingerprint → Warnung, Suffix `(1)` usw., DB mit tatsächlichem Namen
|
||||||
|
- [ ] Zieldatei fehlt → Fehlermeldung, weder FS noch DB werden geändert
|
||||||
|
- [ ] Ungespeicherte Änderungen bei Selektionswechsel → Hinweisdialog erscheint
|
||||||
|
- [ ] Ungespeicherte Änderungen bei Tabwechsel → Hinweisdialog erscheint
|
||||||
|
- [ ] Ungespeicherte Änderungen beim App-Schließen → Hinweisdialog erscheint
|
||||||
|
- [ ] Ungespeicherte Änderungen bei Laufstart → Hinweisdialog erscheint
|
||||||
|
- [ ] Status `FAILED_*` und `SKIPPED` → Dateiname-Textfeld deaktiviert
|
||||||
|
- [ ] Während eines aktiven Laufs: Detailbereich vollständig deaktiviert
|
||||||
|
|
||||||
|
### Technische DoD
|
||||||
|
- [ ] Spike-Button und `PdfViewerSpike.java` sind vollständig entfernt
|
||||||
|
- [ ] Tab „Verarbeitungslauf" zeigt Tabelle und Detailbereich nebeneinander (SplitPane, 60/40, verschiebbar)
|
||||||
|
- [ ] `ManualFileRenameUseCase` ist im Application-Modul implementiert und unabhängig von der GUI testbar
|
||||||
|
- [ ] headless-Betrieb ist unverändert funktionsfähig
|
||||||
|
- [ ] `mvn clean verify` ist grün
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,924 @@
|
|||||||
|
# V3.1 – UX-Polish und Verlauf-Tab-Reife
|
||||||
|
|
||||||
|
**Status:** Zur Implementierung freigegeben
|
||||||
|
**Erstellt:** 2026-05-05
|
||||||
|
**Überarbeitet:** 2026-05-05 (nach ChatGPT-Review Runden 1, 2 und 3)
|
||||||
|
**Autor:** Marcus (mit Claude als Mentor)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
V3.1 ist der konsequente Nachschlag zu V3.0: Was der Produkttest aufgedeckt hat,
|
||||||
|
wird hier bereinigt. Kein großes Architektur-Feature, kein neues Maven-Modul –
|
||||||
|
**gezielter UX-Schliff und Robustheit**.
|
||||||
|
|
||||||
|
Schwerpunkte:
|
||||||
|
|
||||||
|
1. **Polieren** – sichtbare Schwächen aus dem V3.0-Produkttest beheben
|
||||||
|
(#77, #80, #81, #83, #84, #88, #91)
|
||||||
|
2. **Verlauf-Tab reifen lassen** – Suche, Mehrfachauswahl, DB-Neuanlage
|
||||||
|
(#82, #86, #87)
|
||||||
|
3. **Quick Win** – Mausrad-Zoom im PDF-Viewer als kleiner,
|
||||||
|
wertvoller Gebrauchskomfort (#32)
|
||||||
|
|
||||||
|
Die fachliche Kernverarbeitung bleibt vollständig unverändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einordnung
|
||||||
|
|
||||||
|
V3.0 ist der abgeschlossene Ausgangspunkt. Hexagonale Architektur,
|
||||||
|
Modulstruktur, headless-Betrieb, `.properties`-Konfigurationswahrheit
|
||||||
|
und Flyway-DB-Evolution bleiben unangetastet.
|
||||||
|
|
||||||
|
V3.1 fügt **kein neues Maven-Modul** hinzu.
|
||||||
|
|
||||||
|
**Headless-Betrieb:** Der `adapter-in-cli`-Pfad erhält keine neue Bedienfunktion.
|
||||||
|
Er ist jedoch von der globalen Lock-File-Pfadauflösung (#91) und einer
|
||||||
|
ggf. notwendigen Flyway-Schemamigration (#88) betroffen – beide Änderungen
|
||||||
|
wirken beim Programmstart, unabhängig von GUI oder CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In V3.1 enthalten
|
||||||
|
|
||||||
|
| # | Thema | Kategorie |
|
||||||
|
|---|---|---|
|
||||||
|
| #77 | Fehlende Tooltips | UX |
|
||||||
|
| #80 | Dirty-Indikator für Konfigurations-Tab | UX |
|
||||||
|
| #81 | Enum-Werte statt deutscher Bezeichnungen (Status-ComboBox + Versuche-Tabelle) | UX |
|
||||||
|
| #82 | Verlauf-Tab: Live-Filter bei Suche | GUI |
|
||||||
|
| #83 | KI-Begründung bei SUCCESS-Versuch verwirrend leer | UX |
|
||||||
|
| #84 | Aktionsbuttons nach Laufende nicht sofort reaktiviert | Bug |
|
||||||
|
| #86 | Mehrfachauswahl im Verlauf-Tab (Strg+A, Strg+Klick, Shift+Klick) | GUI |
|
||||||
|
| #87 | Neue leere SQLite-Datenbank anlegen | GUI |
|
||||||
|
| #88 | FAILED_FINAL-Einträge zeigen keine Fehlerursache im Verlauf-Tab | UX |
|
||||||
|
| #91 | Lock-File relativer Pfad – Fallback wie Log-Verzeichnis | Robustheit |
|
||||||
|
| #32 | Mausrad-Zoom in PDF-Vorschau | GUI |
|
||||||
|
|
||||||
|
### Explizit nicht in V3.1
|
||||||
|
|
||||||
|
- Automatischer Scheduler / Quellordner-Überwachung (#22) → V3.x
|
||||||
|
- PDF-Viewer Render-DPI (#23) → V3.2
|
||||||
|
- F1-Hilfe (#69) → V3.2
|
||||||
|
- Dark Mode (#70) → V3.x
|
||||||
|
- Log-Viewer in der GUI (#72) → V3.2
|
||||||
|
- Token- und Kosten-Tracking (#74) → V3.2
|
||||||
|
- Excel-Export (#75) → V3.2
|
||||||
|
- Automatische Update-Prüfung (#76) → V3.2
|
||||||
|
- Änderung der fachlichen Kernverarbeitung
|
||||||
|
- Neue Maven-Module, neue KI-Provider, Architekturbrüche
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unverrückbare Leitplanken (unverändert gegenüber V3.0)
|
||||||
|
|
||||||
|
- Java 21, Maven Multi-Module, hexagonale Architektur
|
||||||
|
- Shade-JAR als primäres Distributionsartefakt
|
||||||
|
- GUI ist Standardstart, `--headless` bleibt vollständig erhalten
|
||||||
|
- `.properties` bleibt die einzige Konfigurationswahrheit
|
||||||
|
- Kein Webserver, kein Applikationsserver
|
||||||
|
- GUI offiziell nur unter Windows; headless für Windows Server / Task Scheduler
|
||||||
|
- JavaFX-Threading: I/O auf Worker-Thread, UI-Updates via `Platform.runLater()`
|
||||||
|
- Kein JavaFX in Domain oder Application
|
||||||
|
- JavaDoc-Standard für alle neuen öffentlichen Ports, Use-Cases, DTOs und Adapter-Methoden
|
||||||
|
- Notwendige Code-Kommentare auf Deutsch; Logging auf Deutsch
|
||||||
|
- Flyway ist die einzige Schema-Evolutionsquelle (kein manuelles DDL im Code)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-Mapping-Tabelle (unverändert gegenüber V3.0)
|
||||||
|
|
||||||
|
Diese Tabelle ist weiterhin die einzige autoritative Quelle für Status-Darstellung
|
||||||
|
in der GUI. Sie gilt verbindlich für alle V3.1-Features, die Statuswerte anzeigen –
|
||||||
|
insbesondere #81 (Status-ComboBox, Versuche-Tabelle).
|
||||||
|
|
||||||
|
**Alle acht Statuswerte müssen vollständig unterstützt werden.**
|
||||||
|
Kein Enum-Rohname darf für Endnutzer sichtbar sein.
|
||||||
|
|
||||||
|
| Domain-Status (`ProcessingStatus`) | GUI-Icon | Farbe | GUI-Text (Tooltip) | Summary-Kategorie |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `SUCCESS` | `✓` | Grün | „Erfolgreich verarbeitet und umbenannt." | erfolgreich |
|
||||||
|
| `FAILED_RETRYABLE` | `↻` | Orange | „Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht." | wird wiederholt |
|
||||||
|
| `FAILED_FINAL` | `×` | Rot | „Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), Passwortschutz oder beschädigte Datei. Kein weiterer automatischer Versuch." | fehlgeschlagen |
|
||||||
|
| `SKIPPED_ALREADY_PROCESSED` | `≡` | Grau | „Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet." | übersprungen |
|
||||||
|
| `SKIPPED_FINAL_FAILURE` | `⊘` | Dunkelgrau | „Endgültig übersprungen nach wiederholten Fehlern." | endgültig übersprungen |
|
||||||
|
| `READY_FOR_AI` | `⟳` | Blau | „Wartet auf Verarbeitung." | – |
|
||||||
|
| `PROPOSAL_READY` | `◇` | Hellblau | „KI-Vorschlag liegt vor, wartet auf Bestätigung." | – |
|
||||||
|
| `PROCESSING` | `▶` | Hellgrau | „Wird gerade verarbeitet." | – |
|
||||||
|
|
||||||
|
**Wichtig:** Farbe ist niemals das einzige Unterscheidungsmerkmal.
|
||||||
|
Icon und Tooltip-Text müssen den Status allein eindeutig beschreiben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX-Polishing-Features
|
||||||
|
|
||||||
|
### #77 – Fehlende Tooltips
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Der V3.0-Produkttest hat GUI-Elemente identifiziert, die noch keinen Tooltip
|
||||||
|
tragen. Die Infrastruktur (`GuiTooltipTexts`, `setTooltip()`) existiert bereits
|
||||||
|
aus #66 – es fehlt nur die konsequente Anwendung.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
Vor der Implementierung führt Claude Code eine **vollständige Bestandsaufnahme**
|
||||||
|
durch: Alle interaktiven Elemente auf allen Tabs werden gegen vorhandene Tooltips
|
||||||
|
geprüft. Maßgeblich ist die Bestandsaufnahme – die Zahl 16 stammt aus dem
|
||||||
|
Produkttest und ist nicht bindend. Werden mehr fehlende Elemente gefunden,
|
||||||
|
werden alle ergänzt.
|
||||||
|
|
||||||
|
Fehlende Tooltips werden in `GuiTooltipTexts` als Konstanten ergänzt und
|
||||||
|
im jeweiligen GUI-Tab via `element.setTooltip(new Tooltip(GuiTooltipTexts.XY))`
|
||||||
|
gesetzt. Keine hartcodierten Strings.
|
||||||
|
|
||||||
|
**Tooltips auf `TableColumn`-Headern (Sonderfall JavaFX):**
|
||||||
|
|
||||||
|
`TableColumn` ist kein normaler JavaFX-Node; `setTooltip()` ist darauf nicht
|
||||||
|
direkt anwendbar. **Kein Skin-/Lookup-Hack.** Falls Header-Tooltips benötigt
|
||||||
|
werden, wird ein `Label` als Column-Graphic gesetzt:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Label headerLabel = new Label("Spaltenname");
|
||||||
|
headerLabel.setTooltip(new Tooltip("Erklärungstext"));
|
||||||
|
column.setGraphic(headerLabel);
|
||||||
|
column.setText("");
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei der Umsetzung muss geprüft werden, dass Sortierung, Header-Breite
|
||||||
|
und bestehendes CSS durch das Column-Graphic-Pattern nicht sichtbar
|
||||||
|
verschlechtert werden.
|
||||||
|
|
||||||
|
Falls das Projekt bereits eine stabile eigene Lösung für Column-Tooltips
|
||||||
|
besitzt, wird diese wiederverwendet.
|
||||||
|
|
||||||
|
**Zu prüfende Tabs und Elemente (Anhaltspunkte):**
|
||||||
|
|
||||||
|
| Tab | Verdächtige Elemente |
|
||||||
|
|---|---|
|
||||||
|
| Verlauf | Tabellenspalten-Header, Suchfeld, Such-Button, Aktions-Buttons (Reset, Löschen) |
|
||||||
|
| Verlauf (Detail) | Status-Icon, Versuche-Tabelle Spalten, KI-Begründung-Bereich |
|
||||||
|
| Prompt | Speichern-Button, Zurücksetzen-Button, TextArea |
|
||||||
|
| Allgemein | Fortschrittsbalken, Summary-Banner-Elemente |
|
||||||
|
|
||||||
|
**Technisch:** Ausschließlich `adapter-in-gui` und `GuiTooltipTexts`.
|
||||||
|
Keine Architektur-Änderungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #80 – Dirty-Indikator für Konfigurations-Tab
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Der Prompt-Tab zeigt bereits einen `*`-Dirty-Indikator im Tab-Titel und warnt
|
||||||
|
beim Verlassen mit ungespeicherten Änderungen. Der Konfigurations-Tab hat dieses
|
||||||
|
Verhalten nicht – Nutzer verlieren versehentlich Änderungen.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Dirty-State-Tracking mit Baseline-Snapshot:**
|
||||||
|
|
||||||
|
Beim Laden einer Konfiguration wird ein **Baseline-Snapshot** des geladenen Zustands
|
||||||
|
gespeichert. Dirty-State entsteht durch Vergleich des aktuellen Formularinhalts
|
||||||
|
mit dem Snapshot – nicht durch blindes „erster Listener feuert".
|
||||||
|
|
||||||
|
Während programmgesteuertem Laden oder Normalisieren von Feldinhalten wird
|
||||||
|
Dirty-Tracking temporär unterdrückt (Flag `loadingInProgress`), damit
|
||||||
|
programmatische Feldänderungen keinen unechten Dirty-State auslösen.
|
||||||
|
|
||||||
|
- Beim ersten echten Nutzerwechsel gegenüber dem Snapshot: Tab-Titel wechselt
|
||||||
|
auf `* Konfiguration`
|
||||||
|
- Dirty-Flag wird zurückgesetzt bei: Speichern, Speichern unter,
|
||||||
|
Laden einer neuen Konfiguration (nach Bestätigungsdialog)
|
||||||
|
|
||||||
|
**Bestätigungsdialog bei Navigation mit Dirty State:**
|
||||||
|
|
||||||
|
Beim Laden einer neuen Konfiguration oder beim Schließen der Anwendung
|
||||||
|
mit ungespeicherten Konfig-Änderungen:
|
||||||
|
> „Die Konfiguration enthält ungespeicherte Änderungen. Jetzt speichern?"
|
||||||
|
> [Speichern] [Verwerfen] [Abbrechen]
|
||||||
|
|
||||||
|
**Kopplung mit #87 (Neue Datenbank):**
|
||||||
|
|
||||||
|
Legt der Nutzer über „Neue Datenbank anlegen..." eine neue DB-Datei an,
|
||||||
|
wird der DB-Pfad im Konfigurationsmodell geändert und der Konfig-Tab
|
||||||
|
in den Dirty-State versetzt. Der bestehende Bestätigungsdialog greift
|
||||||
|
beim nächsten Schließen oder Ladevorgang.
|
||||||
|
|
||||||
|
**UX-Konsistenz mit Prompt-Tab:**
|
||||||
|
|
||||||
|
Die UX muss identisch zum Prompt-Tab sein: Sternchen im Tab-Titel,
|
||||||
|
Warn-/Speicherdialog beim Verlassen, Rücksetzen nach Speichern.
|
||||||
|
Die **technische Umsetzung** darf im Konfig-Tab über Baseline-Snapshot
|
||||||
|
und `loadingInProgress` erfolgen, wenn die komplexere Formularlogik
|
||||||
|
das erfordert.
|
||||||
|
|
||||||
|
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #81 – Enum-Werte statt deutscher Bezeichnungen
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Die Status-ComboBox im Verlauf-Tab zeigt rohe Enum-Namen (`READY_FOR_AI`,
|
||||||
|
`FAILED_FINAL` etc.). Die Versuche-Tabelle im Detailbereich zeigt ebenfalls
|
||||||
|
Enum-Rohnamen in der Status-Spalte. Das ist für Endnutzer unlesbar.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Anzeige-Mapping:**
|
||||||
|
|
||||||
|
`ProcessingStatusPresentation` (existiert bereits aus #51) stellt die Mapping-Logik
|
||||||
|
bereit. Dieses Mapping wird für alle Statusanzeigen im Verlauf-Tab verbindlich genutzt.
|
||||||
|
**Alle acht Statuswerte der autoritativen Tabelle müssen abgedeckt sein:**
|
||||||
|
|
||||||
|
| Enum-Wert | Angezeigter Text |
|
||||||
|
|---|---|
|
||||||
|
| `SUCCESS` | „✓ Erfolgreich" |
|
||||||
|
| `FAILED_RETRYABLE` | „↻ Temporärer Fehler" |
|
||||||
|
| `FAILED_FINAL` | „× Dauerhaft fehlgeschlagen" |
|
||||||
|
| `SKIPPED_ALREADY_PROCESSED` | „≡ Bereits verarbeitet" |
|
||||||
|
| `SKIPPED_FINAL_FAILURE` | „⊘ Endgültig übersprungen" |
|
||||||
|
| `READY_FOR_AI` | „⟳ Wartet auf Verarbeitung" |
|
||||||
|
| `PROPOSAL_READY` | „◇ Vorschlag vorhanden" |
|
||||||
|
| `PROCESSING` | „▶ In Bearbeitung" |
|
||||||
|
|
||||||
|
**Status-ComboBox:**
|
||||||
|
|
||||||
|
- Erster Eintrag: „Alle Status" – GUI-intern als `Optional.empty()` bzw. `null`-Filter
|
||||||
|
behandelt; kein Domain-Enum-Wert
|
||||||
|
- Weitere Einträge: alle acht Statuswerte mit Displaytext
|
||||||
|
- Intern wird für DB-Queries stets der Enum-Name verwendet
|
||||||
|
- `StringConverter<ProcessingStatus>` implementieren
|
||||||
|
|
||||||
|
**Versuche-Tabelle (Detailbereich):**
|
||||||
|
|
||||||
|
- Status-Spalte: `ProcessingStatusPresentation`-Mapping anwenden
|
||||||
|
- Kein Enum-Rohname darf für Endnutzer sichtbar sein
|
||||||
|
|
||||||
|
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #83 – KI-Begründung bei SUCCESS-Versuch verwirrend leer
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Im Detailbereich wird bei einem Versuch mit Status `SUCCESS` die
|
||||||
|
KI-Begründungs-TextArea leer angezeigt. Nutzer verstehen nicht, ob das
|
||||||
|
ein Fehler ist oder ob tatsächlich keine Begründung vorliegt.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Platzhalter über JavaFX `promptText` (kein echter Textinhalt):**
|
||||||
|
|
||||||
|
Bei leerem oder null `ai_reasoning` gilt:
|
||||||
|
|
||||||
|
```java
|
||||||
|
textArea.setText("");
|
||||||
|
textArea.setPromptText("Keine KI-Begründung für diesen Versuch gespeichert.");
|
||||||
|
```
|
||||||
|
|
||||||
|
Der `promptText` wird von JavaFX automatisch gedimmt dargestellt und ist
|
||||||
|
**nicht kopierbar, nicht speicherbar, nicht als Nutzdaten behandelbar**.
|
||||||
|
Kein Vermischen von Daten und UI-Platzhaltertext.
|
||||||
|
|
||||||
|
Die TextArea bleibt sichtbar – ein leeres Feld ohne Erklärung ist schlechter
|
||||||
|
als ein erklärender Platzhalter.
|
||||||
|
|
||||||
|
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case,
|
||||||
|
keine DB-Änderung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #84 – Aktionsbuttons nach Laufende nicht sofort reaktiviert
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Nach Abschluss eines Verarbeitungslaufs bleiben die Aktionsbuttons im Verlauf-Tab
|
||||||
|
(„Status zurücksetzen", „Eintrag löschen") dauerhaft deaktiviert.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Ereignisgetriebene Button-State-Neuberechnung:**
|
||||||
|
|
||||||
|
Der Button-State wird nach jedem Lauf-Terminierungsereignis neu berechnet –
|
||||||
|
unabhängig vom Grund der Terminierung:
|
||||||
|
|
||||||
|
- Erfolgreicher Laufabschluss
|
||||||
|
- Fehlerabbruch (Exception im Worker)
|
||||||
|
- Nutzerabbruch
|
||||||
|
- Leerlauf (keine Dateien zu verarbeiten)
|
||||||
|
|
||||||
|
Nach Terminierung wird, sofern eine Auswahl in der Verlauf-Tabelle besteht,
|
||||||
|
der zugehörige Aktionsbutton-State **ereignisgetrieben** aktiviert –
|
||||||
|
ohne dass der Nutzer die Auswahl erneuern oder den Tab wechseln muss.
|
||||||
|
|
||||||
|
**Code-Analyse erforderlich:** Claude Code analysiert den genauen Signal-Pfad
|
||||||
|
(Laufabschluss-Event → UI-Komponente) und korrigiert die fehlende
|
||||||
|
`Platform.runLater()`-Kopplung.
|
||||||
|
|
||||||
|
**Technisch:** Vermutlich `adapter-in-gui` und ggf. `bootstrap` (Bridge-Verdrahtung).
|
||||||
|
Kein neuer Port, kein Use-Case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #88 – FAILED_FINAL ohne Fehlerursache im Verlauf-Tab
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Der Detailbereich zeigt bei `FAILED_FINAL`-, `FAILED_RETRYABLE`- und
|
||||||
|
`SKIPPED_FINAL_FAILURE`-Einträgen keine Fehlerursache an.
|
||||||
|
Der Nutzer sieht nur den Status-Icon.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Schema-/Code-Analyse als blockierender erster Schritt:**
|
||||||
|
|
||||||
|
Vor jeder weiteren Implementierung dokumentiert Claude Code verbindlich,
|
||||||
|
welcher Fall vorliegt:
|
||||||
|
|
||||||
|
**Fall A – geeignetes Fehlerfeld bereits vorhanden:**
|
||||||
|
`processing_attempt` enthält bereits ein nutzbares Fehlerfeld.
|
||||||
|
→ Keine Migration. GUI und Abfrage werden um die Anzeige erweitert.
|
||||||
|
|
||||||
|
**Fall B – kein geeignetes Fehlerfeld vorhanden:**
|
||||||
|
→ Flyway-Migration mit der **nächsten freien Versionsnummer** zum Zeitpunkt
|
||||||
|
der Implementierung. Fehlerdetails können nur für ab V3.1 erzeugte
|
||||||
|
Verarbeitungsversuche gespeichert werden. Bestehende Einträge bleiben
|
||||||
|
unverändert und zeigen den Platzhalter „Keine Fehlerdetails gespeichert."
|
||||||
|
|
||||||
|
**Fall C – Fehlerdetails werden bisher nur im Log gespeichert:**
|
||||||
|
→ Migration zwingend erforderlich. Zusätzlich muss der Fehlerpfad der
|
||||||
|
Verarbeitungslogik um Persistierung der Fehlerdetails erweitert werden.
|
||||||
|
|
||||||
|
**Domain-Modul-Einschränkung:**
|
||||||
|
|
||||||
|
`pdf-umbenenner-domain` bleibt unverändert, sofern die benötigten
|
||||||
|
Fehlerdetails ausschließlich über bestehende oder application-nahe
|
||||||
|
History-DTOs transportiert werden können.
|
||||||
|
|
||||||
|
Falls das fachliche Attempt-Modell im Domain-Modul liegt und für die
|
||||||
|
Anzeige erweitert werden muss, ist eine **minimale Domain-Erweiterung zulässig**.
|
||||||
|
Keine Änderung an der fachlichen Kernverarbeitung.
|
||||||
|
|
||||||
|
**Datenmodell (bei Migration – Fall B oder C):**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Versionsnummer = nächste freie Flyway-Version zum Zeitpunkt der Implementierung
|
||||||
|
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
`failure_details` enthält eine **nutzerverständliche, gekürzte Fehlerbeschreibung**.
|
||||||
|
Provider- oder Exception-Meldungen werden **nicht roh persistiert** –
|
||||||
|
gespeichert wird eine kontrolliert erzeugte Kurzmeldung aus bekannten
|
||||||
|
Fehlerkategorien oder eine bereinigte/gekürzte Message ohne Stacktrace,
|
||||||
|
API-Keys oder vollständige Provider-Rohantworten.
|
||||||
|
|
||||||
|
Die Begrenzung auf **1000 Zeichen wird spätestens vor Persistierung im
|
||||||
|
DB-Adapter erzwungen**: Längere Texte werden gekürzt und mit „…" markiert.
|
||||||
|
Falls bereits vorher ein zentrales Fehler-Mapping existiert, darf dort
|
||||||
|
gekürzt werden. Entscheidend: in die DB gelangen nur gekürzte, bereinigte
|
||||||
|
Fehlerdetails. Kein SQL-`CHECK`-Constraint (um Alt-/Importdaten nicht
|
||||||
|
zu blockieren).
|
||||||
|
|
||||||
|
**„Letzter Versuch" – Definition:**
|
||||||
|
|
||||||
|
Die angezeigte Fehlerursache stammt aus dem Versuch mit dem höchsten
|
||||||
|
`attempt_number`. Bei Gleichstand wird der mit dem jüngsten `ended_at` verwendet.
|
||||||
|
|
||||||
|
Die Sortierung wird im Rahmen der Code-Analyse gegen das vorhandene Schema
|
||||||
|
verifiziert. Falls `attempt_number` oder `ended_at` nicht existieren, wird
|
||||||
|
die technisch eindeutige Sortierung des Attempt-Verlaufs verwendet und
|
||||||
|
in der Implementierungsnotiz dokumentiert.
|
||||||
|
|
||||||
|
**Anzuzeigende Status:**
|
||||||
|
|
||||||
|
Fehlerursache wird angezeigt bei:
|
||||||
|
- `FAILED_FINAL`
|
||||||
|
- `FAILED_RETRYABLE`
|
||||||
|
- `SKIPPED_FINAL_FAILURE` (zeigt die letzte bekannte Fehlerursache des
|
||||||
|
zugrundeliegenden fehlgeschlagenen Attempts – fachlich konsistent,
|
||||||
|
da `SKIPPED_FINAL_FAILURE` direkte Folge eines endgültigen Fehlschlags ist)
|
||||||
|
|
||||||
|
Bei fehlendem `failure_details` (NULL oder leer): Platzhaltertext via `promptText`
|
||||||
|
analog zu #83.
|
||||||
|
|
||||||
|
**Technisch:** `adapter-in-gui` (Anzeige), ggf. `adapter-out-db`
|
||||||
|
(Abfrage-Erweiterung), ggf. Flyway-Migration, ggf. minimale Domain-Erweiterung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #91 – Lock-File relativer Pfad
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Der Lock-Mechanismus nutzt einen konfigurierten oder Standard-Pfad für die
|
||||||
|
Lock-Datei. Bei relativem Pfad ist das Verzeichnis abhängig vom aktuellen
|
||||||
|
Arbeitsverzeichnis. Liegt die JAR unter `C:\Program Files`, ist das Verzeichnis
|
||||||
|
zudem nicht beschreibbar.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Verhalten abhängig vom Pfadtyp:**
|
||||||
|
|
||||||
|
**Absolut konfigurierter Pfad:**
|
||||||
|
Wird unverändert verwendet. Schlägt das Anlegen fehl, erfolgt **kein Fallback** –
|
||||||
|
der Nutzer hat den Speicherort explizit vorgegeben. Start bricht mit klarer
|
||||||
|
Fehlermeldung ab.
|
||||||
|
|
||||||
|
**Relativer oder nicht konfigurierter (Default-)Pfad – zweistufige Fallback-Strategie:**
|
||||||
|
|
||||||
|
1. **Primär:** Auflösung relativ zum Verzeichnis der JAR-Datei
|
||||||
|
(`CodeSource.getLocation()`)
|
||||||
|
2. **Fallback:** Auflösung relativ zu `user.home`
|
||||||
|
3. **Abbruch:** Erst wenn auch `user.home` fehlschlägt
|
||||||
|
|
||||||
|
**Parent-Verzeichnisse** werden bei Bedarf automatisch angelegt
|
||||||
|
(`Files.createDirectories()`).
|
||||||
|
|
||||||
|
Der final verwendete **absolute Pfad wird beim Start geloggt** (INFO-Level):
|
||||||
|
```
|
||||||
|
Lock-Datei: C:\Users\Funny\Documents\pdf-umbenenner.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gilt für GUI- und Headless-Start.**
|
||||||
|
|
||||||
|
**Code-Analyse erforderlich:** Claude Code ermittelt die aktuelle
|
||||||
|
Lock-Implementierungslokation (`bootstrap` oder `adapter-out-db`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GUI-Features
|
||||||
|
|
||||||
|
### #82 – Verlauf-Tab: Live-Filter bei Suche
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Die Suche im Verlauf-Tab wird nur durch expliziten Klick auf den Such-Button
|
||||||
|
ausgelöst. Das erfordert unnötige Interaktion bei jeder Suchanpassung.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Live-Filter mit Debounce und Generation-Counter:**
|
||||||
|
|
||||||
|
- Das Suchfeld erhält einen `ChangeListener` auf die `textProperty()`
|
||||||
|
- Bei jeder Texteingabe startet ein JavaFX-`Timeline`-Debounce-Timer (300 ms)
|
||||||
|
- Nach 300 ms ohne weitere Eingabe wird die DB-Abfrage auf einem Worker-Thread gestartet
|
||||||
|
|
||||||
|
**Race-Condition-Schutz via Generation-Counter:**
|
||||||
|
|
||||||
|
Jede gestartete Suchanfrage erhält eine aufsteigende Generations-ID (atomarer
|
||||||
|
`long`-Counter). Der Worker-Thread trägt seine Generations-ID ins Ergebnis.
|
||||||
|
Beim `Platform.runLater()`-Callback wird das Ergebnis nur in die UI übernommen,
|
||||||
|
wenn die Generations-ID noch aktuell ist – veraltete Worker-Ergebnisse
|
||||||
|
werden verworfen.
|
||||||
|
|
||||||
|
**Such-Button und Enter-Taste:**
|
||||||
|
|
||||||
|
- Klick auf Such-Button oder Enter im Suchfeld: Debounce-Timer sofort abgebrochen,
|
||||||
|
Suche unverzüglich gestartet
|
||||||
|
- Barrierefreiheit: Such-Button bleibt erhalten
|
||||||
|
|
||||||
|
**Auswahlverhalten nach neuen Suchergebnissen:**
|
||||||
|
|
||||||
|
Nach jeder Übernahme neuer Suchergebnisse wird die Tabellenauswahl
|
||||||
|
**vollständig geleert**. Detailbereich und Aktionsbuttons werden entsprechend
|
||||||
|
zurückgesetzt. Das ist robuster als ein Abgleich der alten Auswahl gegen
|
||||||
|
die neue Ergebnisliste und vermeidet Wechselwirkungen mit #86.
|
||||||
|
|
||||||
|
**Leeres Suchfeld:** Zeigt alle Einträge (bis LIMIT 501).
|
||||||
|
|
||||||
|
**Technisch:** Ausschließlich `adapter-in-gui`. Die bestehende Suchabfrage via
|
||||||
|
`GuiHistoryOverviewPort` wird unverändert wiederverwendet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #86 – Mehrfachauswahl im Verlauf-Tab
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Der Verlauf-Tab erlaubt nur Einzelauswahl. Bulk-Operationen sind nicht möglich.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Multi-Select-Modus:**
|
||||||
|
|
||||||
|
```java
|
||||||
|
tableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||||
|
```
|
||||||
|
|
||||||
|
JavaFX stellt damit Strg+Klick und Shift+Klick automatisch bereit.
|
||||||
|
|
||||||
|
**Strg+A – Fokusabhängig:**
|
||||||
|
|
||||||
|
Strg+A selektiert alle sichtbaren Tabelleneinträge **nur, wenn die Verlauf-Tabelle
|
||||||
|
den Fokus besitzt**. Liegt der Fokus im Suchfeld, bleibt Strg+A die normale
|
||||||
|
Textauswahl im Suchfeld.
|
||||||
|
|
||||||
|
**Detailbereich bei Mehrfachauswahl:**
|
||||||
|
|
||||||
|
- Genau 1 Eintrag: Detailbereich wie bisher
|
||||||
|
- Mehrere Einträge: Platzhaltertext „X Einträge ausgewählt."
|
||||||
|
|
||||||
|
**Snapshot der fachlichen Schlüssel vor Worker-Thread-Start:**
|
||||||
|
|
||||||
|
Vor dem Start einer Bulk-Operation wird ein **unveränderlicher Snapshot der
|
||||||
|
fachlichen Schlüssel** erstellt, die die bestehenden Reset-/Delete-Use-Cases
|
||||||
|
erwarten (typischerweise Fingerprints, sofern das die vorhandene Use-Case-Signatur
|
||||||
|
erwartet). Der Worker-Thread arbeitet ausschließlich auf diesem Snapshot –
|
||||||
|
nie auf einer Live-`ObservableList`, die sich während der Operation ändern könnte.
|
||||||
|
|
||||||
|
**Aktionsbuttons bei Mehrfachauswahl:**
|
||||||
|
|
||||||
|
| Aktion | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| „Status zurücksetzen" | Aktiv bei ≥ 1 Auswahl; Bestätigungsdialog: „X Einträge zurücksetzen?" |
|
||||||
|
| „Eintrag löschen" | Aktiv bei ≥ 1 Auswahl; Bestätigungsdialog: „X Einträge unwiderruflich löschen?" |
|
||||||
|
|
||||||
|
**Bulk-Fehlerstrategie (Partial Success):**
|
||||||
|
|
||||||
|
Schlägt eine Operation bei einzelnen Einträgen fehl, werden die restlichen
|
||||||
|
trotzdem abgearbeitet. Nach Abschluss erscheint ein **kompakter**
|
||||||
|
Zusammenfassungsdialog:
|
||||||
|
> „X von Y Einträgen erfolgreich verarbeitet. Z Einträge konnten nicht
|
||||||
|
> verarbeitet werden."
|
||||||
|
|
||||||
|
Detaillierte Einzelfehler werden geloggt, nicht in den Dialog gestopft.
|
||||||
|
|
||||||
|
**Ausführung:** Bulk-Operationen rufen die bestehenden Use-Cases
|
||||||
|
(`DefaultResetDocumentStatusUseCase`, `DefaultDeleteDocumentHistoryUseCase`)
|
||||||
|
sequenziell auf dem Worker-Thread auf. Keine neuen Use-Cases erforderlich.
|
||||||
|
|
||||||
|
**Sperren während Lauf:** Alle Aktions-Buttons deaktiviert während eines
|
||||||
|
aktiven Verarbeitungslaufs.
|
||||||
|
|
||||||
|
**Technisch:** Ausschließlich `adapter-in-gui`. Keine neuen Ports oder Use-Cases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #87 – Neue leere SQLite-Datenbank anlegen
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Will der Nutzer mit einer frischen Datenbank starten, muss er die Datei
|
||||||
|
manuell löschen. Das ist umständlich und fehleranfällig.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Neuer Menüpunkt:**
|
||||||
|
|
||||||
|
`Datenbank → Neue Datenbank anlegen...`
|
||||||
|
|
||||||
|
(Nur aktiv wenn kein Verarbeitungslauf läuft.)
|
||||||
|
|
||||||
|
**Eigentümer des aktiven Datenbankkontexts:**
|
||||||
|
|
||||||
|
Der Runtime-Wechsel der aktiven Datenbank erfordert eine zentrale Komponente,
|
||||||
|
die den aktiven Datenbankkontext besitzt. Vor der Implementierung analysiert
|
||||||
|
Claude Code, ob eine solche Komponente bereits existiert.
|
||||||
|
|
||||||
|
- **Fall A – wechselbarer DB-Kontext vorhanden:** Vorhandene Komponente
|
||||||
|
wird genutzt/erweitert.
|
||||||
|
- **Fall B – kein wechselbarer DB-Kontext vorhanden:** Es wird ein minimaler
|
||||||
|
`ActiveDatabaseContextPort` eingeführt (Outbound-Port in `application`,
|
||||||
|
Adapter in `bootstrap` oder `adapter-out-db`). Dieser Port ist die einzige
|
||||||
|
Stelle, an der die aktive DB-Referenz umgestellt wird.
|
||||||
|
|
||||||
|
**Der DB-Wechsel darf nicht im JavaFX-Code versteckt werden.**
|
||||||
|
Der Use-Case `DefaultCreateNewDatabaseUseCase` orchestriert den Wechsel;
|
||||||
|
die physische Umstellung der Verbindung delegiert er über den Port.
|
||||||
|
|
||||||
|
**Ablauf (atomar aus Anwendungssicht):**
|
||||||
|
|
||||||
|
1. `FileChooser` öffnet (Filter: `*.sqlite`); Nutzer wählt Zieldatei
|
||||||
|
2. **Pfad-Sicherheitsprüfung:**
|
||||||
|
Die aktive DB und die gewählte Zieldatei werden über **normalisierte,
|
||||||
|
absolut aufgelöste Pfade** verglichen – kein Rohstring-Vergleich.
|
||||||
|
Für existierende Dateien wird `toRealPath()` verwendet; für noch nicht
|
||||||
|
existierende Dateien wird der Parent-Pfad real aufgelöst und der Dateiname
|
||||||
|
normalisiert verglichen. Unter Windows erfolgt der Vergleich case-insensitive.
|
||||||
|
Bei Übereinstimmung: klare Fehlermeldung, kein Überschreiben.
|
||||||
|
3. Existiert die Zieldatei (andere als aktive DB): Bestätigungsdialog
|
||||||
|
„Die Datei existiert bereits. Überschreiben?"
|
||||||
|
4. **GUI-Sperre:** Während Anlage und Wechsel befindet sich die GUI in einem
|
||||||
|
`DB-Busy`-Zustand. Alle DB-lesenden und DB-schreibenden Aktionen
|
||||||
|
(Live-Suche, Bulk-Reset, Bulk-Delete, Verlauf-Refresh, erneuter
|
||||||
|
Klick auf „Neue Datenbank anlegen") sind deaktiviert. Der Zustand
|
||||||
|
wird nach Erfolg oder Fehler zuverlässig zurückgesetzt.
|
||||||
|
5. Neue SQLite-Datei wird als **temporäre Datei im Zielverzeichnis** erzeugt
|
||||||
|
6. Flyway führt alle verfügbaren Migrationsskripte gegen die temporäre Datei aus
|
||||||
|
(`migrate()` auf neuesten Schema-Stand)
|
||||||
|
7. Neue DB-Verbindung wird **testweise geöffnet und geprüft** (gegen Temp-Datei).
|
||||||
|
Der Verbindungstest prüft mindestens:
|
||||||
|
- SQLite-Verbindung kann geöffnet werden
|
||||||
|
- Flyway-Schema-History ist vorhanden
|
||||||
|
- Eine einfache Leseabfrage gegen Schema-Metadaten ist erfolgreich
|
||||||
|
8. Erst nach erfolgreichem Test: temporäre Datei zur Zieldatei verschoben.
|
||||||
|
Bei bereits existierender, bestätigter Zieldatei wird
|
||||||
|
`Files.move(tempFile, targetFile, ATOMIC_MOVE, REPLACE_EXISTING)` verwendet,
|
||||||
|
sofern vom Dateisystem unterstützt. Die vorhandene Zieldatei wird vorher
|
||||||
|
**nicht separat gelöscht**. Wird die Kombination `ATOMIC_MOVE + REPLACE_EXISTING`
|
||||||
|
nicht unterstützt, bricht der Vorgang mit klarer Fehlermeldung ab –
|
||||||
|
kein unsicherer halb-atomarer Fallback.
|
||||||
|
9. Aktive DB-Referenz der Anwendung umgestellt (via `ActiveDatabaseContextPort`)
|
||||||
|
10. Verlauf-Tab neu geladen → zeigt „Noch keine Verarbeitungen vorhanden."
|
||||||
|
11. Statuszeile aktualisiert DB-Pfad
|
||||||
|
12. DB-Pfad im Konfigurationsmodell geändert → Konfig-Tab wechselt in Dirty-State
|
||||||
|
13. Statuszeile oder Meldungsbereich zeigt:
|
||||||
|
„Neue Datenbank ist aktiv. Konfiguration speichern, damit diese DB
|
||||||
|
beim nächsten Start verwendet wird."
|
||||||
|
|
||||||
|
**Fehlerfall ohne partielle Änderung:**
|
||||||
|
|
||||||
|
Schlägt ein Schritt (Anlegen, Flyway, Verbindungstest, Move) fehl, bleibt die
|
||||||
|
bisher aktive DB **vollständig unverändert in Betrieb**. Die temporäre Datei
|
||||||
|
wird gelöscht. Fehlerdialog mit konkreter Meldung.
|
||||||
|
|
||||||
|
**Headless:** Die Funktion ist ausschließlich GUI-seitig aufrufbar.
|
||||||
|
`adapter-in-cli` ist nicht betroffen.
|
||||||
|
|
||||||
|
**Architektur:**
|
||||||
|
|
||||||
|
| Komponente | Typ | Modul | Zweck |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `CreateNewDatabaseUseCase` | Inbound-Port-Interface | `application` | Vertrag: `createNewDatabase(Path)` |
|
||||||
|
| `DefaultCreateNewDatabaseUseCase` | Use-Case-Impl. | `application` | Atomarer DB-Wechsel: Temp-Datei, Flyway, Test, Move, Kontext-Umstellung |
|
||||||
|
| `DatabaseCreationPort` | Outbound-Port | `application` | `createAndInitialize(Path tempFile)` |
|
||||||
|
| `ActiveDatabaseContextPort` | Outbound-Port | `application` | `switchActiveDatabase(Path newDbFile)` – Eigentümer des Laufzeitkontexts |
|
||||||
|
| `GuiCreateNewDatabasePort` | Bridge-Interface | `adapter-in-gui` | Brücke zum Use-Case |
|
||||||
|
| `SqliteDatabaseCreationAdapter` | Outbound-Adapter | `adapter-out-db` | SQLite-Temp-Datei erzeugen, Flyway migrate auf latest, Verbindung testen |
|
||||||
|
| `SqliteActiveDatabaseContextAdapter` | Outbound-Adapter | `bootstrap` oder `adapter-out-db` | Umschalten der aktiven DB-Referenz (Analyse erforderlich) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### #32 – Mausrad-Zoom in PDF-Vorschau
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
|
||||||
|
Die PDF-Vorschau lässt sich nur über die Zoom-Buttons skalieren.
|
||||||
|
Ein Mausrad-Zoom fehlt.
|
||||||
|
|
||||||
|
#### Lösung
|
||||||
|
|
||||||
|
**Scroll-Event auf der PDF-Vorschau-Komponente:**
|
||||||
|
|
||||||
|
```java
|
||||||
|
scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
|
||||||
|
if (event.isControlDown()) {
|
||||||
|
accumulateAndApplyZoomDelta(event.getDeltaY());
|
||||||
|
event.consume(); // immer konsumieren bei Strg, kein paralleles Scrollen
|
||||||
|
}
|
||||||
|
// ohne Strg: normales Scrollen bleibt
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bei gedrückter Strg-Taste werden ScrollEvents grundsätzlich konsumiert**,
|
||||||
|
damit kein paralleles Scrollen im ScrollPane erfolgt – auch wenn der Delta
|
||||||
|
zu klein für einen Zoomschritt ist.
|
||||||
|
|
||||||
|
**Delta-Akkumulation für Trackpad-Kompatibilität:**
|
||||||
|
|
||||||
|
Sehr kleine Trackpad-Deltas werden **intern akkumuliert**, bis die Mindestschwelle
|
||||||
|
für einen Zoomschritt erreicht ist. Kein Verwerfen: akkumulierte Deltas
|
||||||
|
ergeben bei genug Trackpad-Wischbewegung sauber einen Zoomschritt.
|
||||||
|
Als Orientierungswert gilt ±10 % je „Notch" eines Standard-Mausrads.
|
||||||
|
|
||||||
|
**Zoom-Verhalten:**
|
||||||
|
|
||||||
|
| Parameter | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Auslöser | Strg + Mausrad |
|
||||||
|
| Schrittweite | Vorzeichenbasiert auf akkumuliertem `deltaY`, ca. 10 % je Notch |
|
||||||
|
| Minimum | 10 % |
|
||||||
|
| Maximum | 500 % |
|
||||||
|
| Zurücksetzen bei neuem PDF | Ja (Zoom auf Fit-to-Width) |
|
||||||
|
|
||||||
|
**Fit-to-Width-Modus:**
|
||||||
|
|
||||||
|
Nach manuellem Strg+Mausrad-Zoom verlässt die Vorschau den Fit-to-Width-Modus.
|
||||||
|
Fit-to-Width wird erst wieder aktiv, wenn ein neues PDF geladen oder der
|
||||||
|
Fit-to-Width-Button explizit erneut betätigt wird.
|
||||||
|
|
||||||
|
**Viewport-Stabilität:**
|
||||||
|
|
||||||
|
Beim Zoom bleibt die sichtbare Viewport-Mitte möglichst erhalten.
|
||||||
|
|
||||||
|
**Zoom-State-Konsistenz:**
|
||||||
|
|
||||||
|
Der Zoom-State wird über dieselbe Variable geführt, die auch die
|
||||||
|
Toolbar-Zoom-Buttons bedienen.
|
||||||
|
|
||||||
|
**Technisch:** Ausschließlich `adapter-in-gui`. Kein neuer Port, kein Use-Case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Zusammenfassung
|
||||||
|
|
||||||
|
### Neue Inbound-Port-Interfaces und Use-Cases
|
||||||
|
|
||||||
|
| Komponente | Typ | Modul | Zweck | Issue |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CreateNewDatabaseUseCase` | Inbound-Port-Interface | `application` | Vertrag für DB-Anlage | #87 |
|
||||||
|
| `DefaultCreateNewDatabaseUseCase` | Use-Case-Impl. | `application` | Atomarer DB-Wechsel via Temp-Datei + Port-Delegation | #87 |
|
||||||
|
|
||||||
|
### Neue Outbound-Ports
|
||||||
|
|
||||||
|
| Komponente | Modul | Zweck | Issue |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `DatabaseCreationPort` | `application` | Temp-Datei erzeugen, Flyway, Verbindungstest | #87 |
|
||||||
|
| `ActiveDatabaseContextPort` | `application` | `switchActiveDatabase(Path)` – Laufzeit-DB-Kontext | #87 |
|
||||||
|
|
||||||
|
### Neue Bridge-Interfaces (adapter-in-gui)
|
||||||
|
|
||||||
|
| Interface | Zweck | Issue |
|
||||||
|
|---|---|---|
|
||||||
|
| `GuiCreateNewDatabasePort` | Brücke zur DB-Anlage | #87 |
|
||||||
|
|
||||||
|
### Neue Adapter
|
||||||
|
|
||||||
|
| Adapter | Modul | Zweck | Issue |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SqliteDatabaseCreationAdapter` | `adapter-out-db` | SQLite-Temp-Datei, Flyway migrate auf latest, Test | #87 |
|
||||||
|
| `SqliteActiveDatabaseContextAdapter` | `bootstrap` oder `adapter-out-db` | Umschalten der aktiven DB-Referenz (Lokation via Code-Analyse) | #87 |
|
||||||
|
|
||||||
|
### Geänderte Komponenten (adapter-in-gui)
|
||||||
|
|
||||||
|
| Komponente | Änderung | Issues |
|
||||||
|
|---|---|---|
|
||||||
|
| `GuiHistoryTab` | Multi-Select + Schlüssel-Snapshot, Live-Filter + Generation-Counter + Auswahl leeren, Fehlerursache, Platzhalter via promptText, Tooltips, DB-Busy-Sperre | #82, #83, #86, #88, #77, #87 |
|
||||||
|
| `GuiConfigTab` | Dirty-State mit Baseline-Snapshot + loadingInProgress, Tab-Titel, Dialog, Kopplung mit #87 | #80 |
|
||||||
|
| `GuiTooltipTexts` | Neue Tooltip-Konstanten; TableColumn-Header via Column-Graphic-Pattern | #77 |
|
||||||
|
| Verlauf-Detailbereich | Enum-Displaytext (alle 8 Werte), Fehlerursache für FAILED/SKIPPED_FINAL | #81, #88 |
|
||||||
|
| Status-ComboBox | `StringConverter<ProcessingStatus>`, „Alle Status" als GUI-interner Null-Filter | #81 |
|
||||||
|
| PDF-Vorschau-Komponente | Delta-Akkumulation, Strg+Scroll konsumiert, Viewport-Stabilität, Fit-to-Width-Modus | #32 |
|
||||||
|
| Lauf-Abschluss-Signalkette | Ereignisgetriebene Button-State-Neuberechnung für alle Terminierungsgründe | #84 |
|
||||||
|
|
||||||
|
### Geänderte Komponenten (sonstige)
|
||||||
|
|
||||||
|
| Komponente | Modul | Änderung | Issue |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Lock-File-Auflösung | `bootstrap` oder `adapter-out-db` | Absolut: direkt + Abbruch; Relativ: JAR-Dir → user.home → Abbruch; Parent-Dirs; Logging | #91 |
|
||||||
|
|
||||||
|
### Nicht geändert
|
||||||
|
|
||||||
|
- `pdf-umbenenner-domain` – keine Änderungen, außer ggf. minimale Erweiterung
|
||||||
|
für #88 falls Attempt-Modell dort liegt (zulässig, keine Kernverarbeitungslogik)
|
||||||
|
- `pdf-umbenenner-adapter-in-cli` – keine neuen Funktionen
|
||||||
|
- Headless-Verarbeitungslogik – vollständig unberührt
|
||||||
|
- Kernverarbeitungslogik (PDF lesen → KI → umbenennen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbankmigrationen
|
||||||
|
|
||||||
|
Flyway ist die einzige Schema-Evolutionsquelle.
|
||||||
|
|
||||||
|
### Potenzielles Migrationsskript (abhängig von Code-Analyse #88)
|
||||||
|
|
||||||
|
Vor der Implementierung von #88 dokumentiert Claude Code verbindlich,
|
||||||
|
ob ein Fehlerfeld bereits im Schema existiert (Fall A / B / C – siehe #88).
|
||||||
|
|
||||||
|
**Nur bei Fall B oder C:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Fehlerdetails in processing_attempt ergänzen
|
||||||
|
-- Versionsnummer = nächste freie Flyway-Version zum Zeitpunkt der Implementierung
|
||||||
|
ALTER TABLE processing_attempt ADD COLUMN failure_details TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `failure_details`: nutzerverständliche, gekürzte Fehlerbeschreibung;
|
||||||
|
Begrenzung auf 1000 Zeichen **vor Persistierung im Adapter** erzwungen,
|
||||||
|
Kürzung mit „…"; kein SQL-`CHECK`-Constraint
|
||||||
|
- Bestehende Zeilen erhalten automatisch `NULL` – kein Datenverlust
|
||||||
|
- Alte Einträge ohne Fehlerdetails zeigen `promptText`-Platzhalter in der GUI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done (V3.1 gesamt)
|
||||||
|
|
||||||
|
- [ ] Alle 11 Issues implementiert und einzeln getestet
|
||||||
|
- [ ] `mvn clean verify` grün (alle Module, kein `-DskipTests`)
|
||||||
|
- [ ] `mvn clean install -Drevision=3.1.0` – Build ohne Fehler
|
||||||
|
- [ ] Manueller GUI-Produkttest durchgeführt (Green build ≠ fertige Software)
|
||||||
|
- [ ] Keine Enum-Rohnamen in der GUI sichtbar (alle 8 Statuswerte mit Displaytext)
|
||||||
|
- [ ] Alle fehlenden Tooltips vorhanden; TableColumn-Header via Column-Graphic-Pattern
|
||||||
|
- [ ] Dirty-Indikator Konfig-Tab: kein programmgesteuertes Feuern, Baseline-Snapshot korrekt
|
||||||
|
- [ ] Live-Filter: 300 ms Debounce, Generation-Counter, Auswahl nach Suche geleert
|
||||||
|
- [ ] Mehrfachauswahl: Strg+A nur bei Tabellenfokus; Schlüssel-Snapshot; Partial-Success-Dialog
|
||||||
|
- [ ] `FAILED_FINAL`/`FAILED_RETRYABLE`/`SKIPPED_FINAL_FAILURE`: Fehlerursache sichtbar (oder Platzhalter)
|
||||||
|
- [ ] Leere `ai_reasoning`: `promptText`-Platzhalter (kein echter Text)
|
||||||
|
- [ ] Aktionsbuttons ereignisgetrieben reaktiviert nach allen Terminierungsgründen
|
||||||
|
- [ ] #87 Code-Analyse: DB-Kontext-Eigentümer dokumentiert (Fall A oder B)
|
||||||
|
- [ ] #87: Atomarer Ablauf via Temp-Datei; Pfadvergleich normalisiert + case-insensitive
|
||||||
|
- [ ] #87: Aktive DB bleibt bei Fehler unverändert; DB-Busy-Sperre korrekt zurückgesetzt
|
||||||
|
- [ ] #87: Flyway auf neuesten Stand; Hinweismeldung nach Wechsel
|
||||||
|
- [ ] Strg+Mausrad-Zoom: Delta-Akkumulation, immer konsumiert bei Strg, 10%–500%
|
||||||
|
- [ ] Lock-File: Absolut direkt; Relativ zweistufig; Parent-Dirs; Pfad geloggt
|
||||||
|
- [ ] Code-Kommentare auf Deutsch; Logging auf Deutsch
|
||||||
|
- [ ] JavaDoc auf allen neuen öffentlichen Ports, Use-Cases und Adapter-Methoden
|
||||||
|
- [ ] `betrieb.md` und `gui-bedienanleitung.md` auf V3.1-Stand gebracht
|
||||||
|
- [ ] Freigabedokument `freigabe-v3_1.md` erstellt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahmekriterien je Feature
|
||||||
|
|
||||||
|
### #77 Fehlende Tooltips
|
||||||
|
- [ ] Vollständige Bestandsaufnahme: Liste aller Elemente ohne Tooltip erstellt
|
||||||
|
- [ ] Alle identifizierten Elemente haben Tooltips (Anzahl aus Bestandsaufnahme)
|
||||||
|
- [ ] TableColumn-Header: Column-Graphic mit Label+Tooltip, kein Skin-/Lookup-Hack
|
||||||
|
- [ ] Column-Graphic: Sortierung, Header-Breite und CSS nicht sichtbar verschlechtert
|
||||||
|
- [ ] Neue Konstanten ausschließlich in `GuiTooltipTexts`, keine hartcodierten Strings
|
||||||
|
|
||||||
|
### #80 Dirty-Indikator Konfig-Tab
|
||||||
|
- [ ] Tab-Titel `* Konfiguration` nur nach echter Nutzeränderung gegenüber Baseline-Snapshot
|
||||||
|
- [ ] Programmgesteuertes Laden setzt kein Dirty-Flag (`loadingInProgress`-Schutz)
|
||||||
|
- [ ] Tab-Titel `Konfiguration` nach Speichern
|
||||||
|
- [ ] Bestätigungsdialog bei Laden neuer Konfig mit Dirty State
|
||||||
|
- [ ] DB-Pfad-Wechsel via #87 setzt Konfig-Tab dirty
|
||||||
|
- [ ] UX identisch zum Prompt-Tab (Sternchen, Dialog, Reset)
|
||||||
|
|
||||||
|
### #81 Enum-Bezeichnungen
|
||||||
|
- [ ] Status-ComboBox: „Alle Status" als erster Eintrag (GUI-interner Null-Filter)
|
||||||
|
- [ ] Status-ComboBox: alle 8 Statuswerte als Displaytext
|
||||||
|
- [ ] Versuche-Tabelle: alle 8 Statuswerte als Displaytext
|
||||||
|
- [ ] DB-Queries intern weiterhin mit Enum-Namen
|
||||||
|
- [ ] Kein Enum-Rohname für Endnutzer sichtbar
|
||||||
|
|
||||||
|
### #82 Live-Filter
|
||||||
|
- [ ] Suche startet nach 300 ms Tipp-Pause automatisch
|
||||||
|
- [ ] Generation-Counter: veraltete Worker-Ergebnisse werden verworfen
|
||||||
|
- [ ] Such-Button / Enter: sofortige Suche, Debounce abgebrochen
|
||||||
|
- [ ] Auswahl nach neuen Suchergebnissen vollständig geleert
|
||||||
|
- [ ] Leeres Suchfeld zeigt alle Einträge
|
||||||
|
- [ ] Worker-Thread, UI via `Platform.runLater()`
|
||||||
|
|
||||||
|
### #83 KI-Begründung leer
|
||||||
|
- [ ] `textArea.setPromptText(...)` bei leerem/null `ai_reasoning`
|
||||||
|
- [ ] `textArea.setText("")` – kein Platzhaltertext als echter Inhalt
|
||||||
|
- [ ] TextArea bleibt sichtbar
|
||||||
|
|
||||||
|
### #84 Buttons reaktivieren
|
||||||
|
- [ ] Aktionsbuttons während Lauf deaktiviert
|
||||||
|
- [ ] Reaktivierung ereignisgetrieben nach: Erfolg, Fehlerabbruch, Nutzerabbruch, Exception
|
||||||
|
- [ ] Keine manuellen Workarounds notwendig
|
||||||
|
|
||||||
|
### #86 Mehrfachauswahl
|
||||||
|
- [ ] `SelectionMode.MULTIPLE` aktiv
|
||||||
|
- [ ] Strg+A nur bei Tabellenfokus (kein Konflikt mit Suchfeld)
|
||||||
|
- [ ] Strg+Klick, Shift+Klick korrekt
|
||||||
|
- [ ] Detailbereich: „X Einträge ausgewählt." bei Mehrfachauswahl
|
||||||
|
- [ ] Schlüssel-Snapshot vor Worker-Thread-Start
|
||||||
|
- [ ] Bulk-Reset: Bestätigungsdialog + Partial-Success-Dialog
|
||||||
|
- [ ] Bulk-Delete: Bestätigungsdialog + Partial-Success-Dialog
|
||||||
|
- [ ] Aktionen während Lauf gesperrt
|
||||||
|
|
||||||
|
### #87 Neue Datenbank anlegen
|
||||||
|
- [ ] Code-Analyse: DB-Kontext-Eigentümer dokumentiert, Fall A oder B entschieden
|
||||||
|
- [ ] Menüpunkt vorhanden, nur außerhalb von Läufen aktiv
|
||||||
|
- [ ] Aktive DB über normalisierten Pfadvergleich (case-insensitive, toRealPath) erkannt
|
||||||
|
- [ ] Bestehende Fremddatei: Überschreiben-Bestätigung
|
||||||
|
- [ ] DB-Busy-Sperre während Anlage aktiv; nach Erfolg/Fehler zuverlässig zurückgesetzt
|
||||||
|
- [ ] Neue DB als Temp-Datei; Flyway auf neuesten Stand
|
||||||
|
- [ ] Verbindungstest: Verbindung öffnen, Flyway-History prüfen, Leseabfrage erfolgreich
|
||||||
|
- [ ] Move mit `ATOMIC_MOVE + REPLACE_EXISTING`; vorhandene Datei nicht vorher separat löschen
|
||||||
|
- [ ] Kein halb-atomarer Fallback bei nicht unterstützter Kombination
|
||||||
|
- [ ] Fehlerfall: Temp-Datei gelöscht, aktive DB unverändert, Fehlerdialog
|
||||||
|
- [ ] `ActiveDatabaseContextPort.switchActiveDatabase()` schaltet Referenz um
|
||||||
|
- [ ] Verlauf-Tab: „Noch keine Verarbeitungen vorhanden."
|
||||||
|
- [ ] Statuszeile aktualisiert DB-Pfad
|
||||||
|
- [ ] Konfig-Tab wechselt in Dirty-State
|
||||||
|
- [ ] Hinweismeldung: Konfiguration speichern nicht vergessen
|
||||||
|
|
||||||
|
### #88 Fehlerursache FAILED_FINAL
|
||||||
|
- [ ] Schema-/Code-Analyse: Fall A/B/C dokumentiert vor Implementierung
|
||||||
|
- [ ] Ggf. Flyway-Migration mit nächster freier Versionsnummer
|
||||||
|
- [ ] Sortierung für „letzter Versuch" gegen Schema verifiziert
|
||||||
|
- [ ] Detailbereich: `failure_details` bei `FAILED_FINAL`, `FAILED_RETRYABLE`, `SKIPPED_FINAL_FAILURE`
|
||||||
|
- [ ] NULL/leer: `promptText`-Platzhalter
|
||||||
|
- [ ] 1000-Zeichen-Grenze spätestens vor DB-Persistierung erzwungen, Kürzung mit „…"
|
||||||
|
- [ ] Keine rohen Provider-/Exception-Meldungen persistiert
|
||||||
|
|
||||||
|
### #91 Lock-File Pfad
|
||||||
|
- [ ] Absoluter Pfad: direkt verwendet, kein Fallback, Abbruch bei Fehler
|
||||||
|
- [ ] Relativer Pfad: erst JAR-Verzeichnis, dann `user.home`, dann Abbruch
|
||||||
|
- [ ] Parent-Verzeichnisse automatisch angelegt
|
||||||
|
- [ ] Absoluter Pfad beim Start geloggt (INFO)
|
||||||
|
- [ ] Gilt für GUI- und Headless-Start
|
||||||
|
|
||||||
|
### #32 Mausrad-Zoom
|
||||||
|
- [ ] Strg+Scroll: Event grundsätzlich konsumiert (kein paralleles Scrollen)
|
||||||
|
- [ ] Delta-Akkumulation für kleine Trackpad-Deltas
|
||||||
|
- [ ] Zoom 10%–500%, ca. 10 % je Notch
|
||||||
|
- [ ] Ohne Strg: normales Scrollen
|
||||||
|
- [ ] Viewport-Mitte beim Zoom möglichst stabil
|
||||||
|
- [ ] Fit-to-Width-Modus verlassen nach manuellem Zoom
|
||||||
|
- [ ] Zoom-Reset bei neuem PDF (Fit-to-Width)
|
||||||
|
- [ ] Zoom-State konsistent mit Toolbar-Zoom-Buttons
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -66,9 +66,9 @@ Fallback auf aktuelles Datum ist erlaubt, wenn kein belastbares Datum eindeutig
|
|||||||
|
|
||||||
### 4.3 Titel
|
### 4.3 Titel
|
||||||
|
|
||||||
- maximal **20 Zeichen (Basistitel)**
|
- maximal **konfigurierbare Anzahl Zeichen (Basistitel, Default 60, gültiger Bereich 10..120)**
|
||||||
- verständlich und eindeutig
|
- verständlich und eindeutig
|
||||||
- keine Sonderzeichen außer Leerzeichen
|
- keine Sonderzeichen außer Leerzeichen, Bindestrichen, Punkten, Kommas und Ampersands
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ Bei Namenskonflikten:
|
|||||||
|
|
||||||
Regel:
|
Regel:
|
||||||
|
|
||||||
- 20 Zeichen gelten nur für den Basistitel
|
- die konfigurierte maximale Titellänge gilt nur für den Basistitel
|
||||||
- Suffix wird zusätzlich ergänzt
|
- Suffix wird zusätzlich ergänzt
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -192,7 +192,7 @@ Ein Ergebnis ist korrekt, wenn:
|
|||||||
|
|
||||||
- Format stimmt
|
- Format stimmt
|
||||||
- Datum korrekt ist
|
- Datum korrekt ist
|
||||||
- Titel max. 20 Zeichen hat
|
- Titel die konfigurierte maximale Länge einhält
|
||||||
- Dubletten korrekt behandelt wurden
|
- Dubletten korrekt behandelt wurden
|
||||||
- Begründung vorhanden ist
|
- Begründung vorhanden ist
|
||||||
- Ergebnis reproduzierbar ist
|
- Ergebnis reproduzierbar ist
|
||||||
@@ -201,12 +201,31 @@ Ein Ergebnis ist korrekt, wenn:
|
|||||||
|
|
||||||
## 14. Nicht-Ziele
|
## 14. Nicht-Ziele
|
||||||
|
|
||||||
- keine manuelle Nachbearbeitung
|
- kein manueller Verarbeitungslauf durch den Benutzer (die KI-Verarbeitungskette
|
||||||
- keine Benutzerinteraktion
|
läuft ausschließlich automatisiert)
|
||||||
- keine Inhaltsänderung von Dokumenten
|
- keine Inhaltsänderung von Dokumenten
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 14a. Manuelle Korrektur des Dateinamens nach automatischer Verarbeitung
|
||||||
|
|
||||||
|
Nach Abschluss eines automatisierten Verarbeitungslaufs kann der Benutzer den von der
|
||||||
|
KI vorgeschlagenen Dateinamen der Zieldatei **manuell korrigieren**.
|
||||||
|
|
||||||
|
Verbindliche Regeln:
|
||||||
|
|
||||||
|
- Die Korrektur ist **optional** und ersetzt keinen erneuten KI-Aufruf.
|
||||||
|
- Der geänderte Dateiname muss denselben Formatregeln genügen wie ein automatisch
|
||||||
|
erzeugter Name (`YYYY-MM-DD - Titel.pdf`, zulässige Sonderzeichen, Titellänge).
|
||||||
|
- Namenskonflikte im Zielordner werden durch Dubletten-Suffix aufgelöst
|
||||||
|
(analog zur automatischen Verarbeitung).
|
||||||
|
- Die Umbenennung ist **atomar**: entweder Dateisystem und Datenbank werden
|
||||||
|
konsistent aktualisiert, oder die Aktion wird vollständig zurückgerollt.
|
||||||
|
- Die Quelldatei bleibt unverändert.
|
||||||
|
- Ein manuell korrigierter Dateiname wird in der Versuchshistorie persistiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 15. Qualitätsanforderungen
|
## 15. Qualitätsanforderungen
|
||||||
|
|
||||||
- deterministisches Verhalten
|
- deterministisches Verhalten
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ YYYY-MM-DD - Titel(2).pdf
|
|||||||
```
|
```
|
||||||
|
|
||||||
Dabei gilt:
|
Dabei gilt:
|
||||||
- die **20 Zeichen** beziehen sich nur auf den **Basistitel**
|
- die **konfigurierte maximale Titellänge** bezieht sich nur auf den **Basistitel**
|
||||||
- das Dubletten-Suffix zählt **nicht** zu diesen 20 Zeichen
|
- das Dubletten-Suffix zählt **nicht** zur konfigurierten Titellänge
|
||||||
- die Quelldatei wird **nie** überschrieben oder verändert
|
- die Quelldatei wird **nie** überschrieben oder verändert
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -133,8 +133,8 @@ Beispiel:
|
|||||||
|
|
||||||
#### Adapter Out
|
#### Adapter Out
|
||||||
Enthält technische Implementierungen der Outbound-Ports, insbesondere:
|
Enthält technische Implementierungen der Outbound-Ports, insbesondere:
|
||||||
- Dateisystem
|
- Dateisystem (inkl. `FilesystemTargetFileRenameAdapter` für atomare Zieldatei-Umbenennung)
|
||||||
- PDFBox
|
- PDFBox (Textauslese sowie direktes Seitenrendering für die GUI-Vorschau via `PDFRenderer.renderImageWithDPI`)
|
||||||
- SQLite
|
- SQLite
|
||||||
- KI-HTTP-Clients (eine Implementierung je unterstütztem Provider, siehe Abschnitt 11)
|
- KI-HTTP-Clients (eine Implementierung je unterstütztem Provider, siehe Abschnitt 11)
|
||||||
- Properties-/Umgebungs-Konfiguration
|
- Properties-/Umgebungs-Konfiguration
|
||||||
@@ -204,12 +204,19 @@ Verbindlich zweckmäßige Outbound-Ports:
|
|||||||
- `FingerprintPort`
|
- `FingerprintPort`
|
||||||
- `ProcessedDocumentRepository`
|
- `ProcessedDocumentRepository`
|
||||||
- `AiNamingPort`
|
- `AiNamingPort`
|
||||||
|
- `TargetFileRenamePort`
|
||||||
- `ConfigurationPort`
|
- `ConfigurationPort`
|
||||||
- `RunLockPort`
|
- `RunLockPort`
|
||||||
- `ClockPort`
|
- `ClockPort`
|
||||||
|
|
||||||
Der `AiNamingPort` bleibt **provider-neutral**. Er kennt weder OpenAI- noch Anthropic-spezifische Typen, Header, URLs oder Antwortformate. Provider-spezifische Details (Endpunkt, Authentifizierung, Request-/Response-Format) leben ausschließlich in den jeweiligen Adapter-Out-Implementierungen.
|
Der `AiNamingPort` bleibt **provider-neutral**. Er kennt weder OpenAI- noch Anthropic-spezifische Typen, Header, URLs oder Antwortformate. Provider-spezifische Details (Endpunkt, Authentifizierung, Request-/Response-Format) leben ausschließlich in den jeweiligen Adapter-Out-Implementierungen.
|
||||||
|
|
||||||
|
Der `TargetFileRenamePort` kapselt die atomare Umbenennung einer bereits kopierten Zieldatei.
|
||||||
|
Er wird vom Use Case `ManualFileRenameUseCase` genutzt und ist durch
|
||||||
|
`FilesystemTargetFileRenameAdapter` implementiert. Der Port-Vertrag enthält keine
|
||||||
|
`Path`- oder NIO-Typen in öffentlichen Signaturen; er arbeitet ausschließlich mit
|
||||||
|
Domain-Typen und String-basierten Dateinamen.
|
||||||
|
|
||||||
### 6.3 Logging
|
### 6.3 Logging
|
||||||
Logging ist **kein fachlicher Port**. Logging ist technische Infrastruktur.
|
Logging ist **kein fachlicher Port**. Logging ist technische Infrastruktur.
|
||||||
|
|
||||||
@@ -290,7 +297,7 @@ Der Titel muss technisch diese Regeln erfüllen:
|
|||||||
- Deutsch
|
- Deutsch
|
||||||
- verständlich
|
- verständlich
|
||||||
- eindeutig genug für den Dokumentkontext
|
- eindeutig genug für den Dokumentkontext
|
||||||
- maximal **20 Zeichen** als Basistitel
|
- maximal die **konfigurierte Titellänge** als Basistitel (Default 60, gültiger Bereich 10..120)
|
||||||
- keine unzulässigen Windows-Dateinamenzeichen
|
- keine unzulässigen Windows-Dateinamenzeichen
|
||||||
- keine generischen Platzhalter wie z. B. `Dokument`, `Datei`, `Scan`, `PDF`
|
- keine generischen Platzhalter wie z. B. `Dokument`, `Datei`, `Scan`, `PDF`
|
||||||
- Eigennamen bleiben unverändert
|
- Eigennamen bleiben unverändert
|
||||||
@@ -532,6 +539,7 @@ Verbindlich zweckmäßige Parameter:
|
|||||||
- `max.retries.transient`
|
- `max.retries.transient`
|
||||||
- `max.pages`
|
- `max.pages`
|
||||||
- `max.text.characters`
|
- `max.text.characters`
|
||||||
|
- `max.title.length`
|
||||||
- `prompt.template.file`
|
- `prompt.template.file`
|
||||||
|
|
||||||
Pro unterstützter Provider-Familie existiert ein eigener Parameter-Namensraum mit zweckmäßig mindestens:
|
Pro unterstützter Provider-Familie existiert ein eigener Parameter-Namensraum mit zweckmäßig mindestens:
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# M14 - Arbeitspakete
|
||||||
|
|
||||||
|
## Geltungsbereich
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt ausschließlich die Arbeitspakete für den definierten Meilenstein
|
||||||
|
**M14 – Windows-EXE-Packaging (V2.5)**.
|
||||||
|
|
||||||
|
Der dokumentierte und freigegebene Stand **V2.0** (Commit `1bb7a427357c73039c09a8e1bfe351dee54df765`)
|
||||||
|
wird als vollständig umgesetzt und freigegeben vorausgesetzt.
|
||||||
|
|
||||||
|
Die Arbeitspakete sind bewusst so geschnitten, dass:
|
||||||
|
|
||||||
|
- **KI 1** daraus je Arbeitspaket einen klaren Einzel-Prompt ableiten kann,
|
||||||
|
- **KI 2** genau dieses eine Arbeitspaket in **einem Durchgang** vollständig umsetzen kann,
|
||||||
|
- nach **jedem** Arbeitspaket wieder ein **fehlerfreier, buildbarer Stand** vorliegt.
|
||||||
|
|
||||||
|
Die Reihenfolge der Arbeitspakete ist verbindlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielbild von M14
|
||||||
|
|
||||||
|
Nach Abschluss von M14 existiert neben dem bestehenden Shade-JAR ein zweites
|
||||||
|
Distributionsartefakt: eine **native Windows-EXE**, die alle notwendigen Laufzeitkomponenten
|
||||||
|
enthält und auf einem frischen Windows 10 (x64) oder Windows Server 2022 (x64) ohne
|
||||||
|
vorinstalliertes Java oder sonstige Laufzeitumgebungen ausführbar ist.
|
||||||
|
|
||||||
|
Die EXE wird ausschließlich **lokal auf der Windows-Entwicklungsmaschine** gebaut,
|
||||||
|
gesteuert über das Maven-Profil `-P release`. Jenkins bleibt für den normalen
|
||||||
|
JAR-Build zuständig und ist von M14 nicht betroffen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abgrenzungen
|
||||||
|
|
||||||
|
### Explizit nicht Bestandteil von M14
|
||||||
|
|
||||||
|
- Windows-Installer (MSI, NSIS, Inno Setup o. Ä.) → V3.0
|
||||||
|
- Code-Signing der EXE → kein kostenfreier Weg für Deutschland verfügbar
|
||||||
|
- Cross-Compilation für andere Betriebssysteme
|
||||||
|
- Änderungen an fachlicher Benennungslogik, Statussemantik, Retry-Regeln oder Persistenz
|
||||||
|
- Änderungen an der GUI oder am headless Batch-Betrieb
|
||||||
|
- Neue Tests für die EXE (manueller Smoke-Test durch den Entwickler)
|
||||||
|
- Jenkins-Integration des EXE-Builds
|
||||||
|
|
||||||
|
### Unveränderte Leitplanken
|
||||||
|
|
||||||
|
- Java 21
|
||||||
|
- Maven Multi-Module
|
||||||
|
- Hexagonale Architektur bleibt unberührt
|
||||||
|
- Das Shade-JAR bleibt das primäre Distributionsartefakt (Änderung in `betrieb.md` erforderlich)
|
||||||
|
- Der normale Build (`mvn verify`) bleibt unverändert und erfordert kein WiX Toolset
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbindliche M14-Regeln für alle Arbeitspakete
|
||||||
|
|
||||||
|
### 1. Neues Maven-Modul
|
||||||
|
|
||||||
|
Das EXE-Packaging wird in einem eigenen Modul `pdf-umbenenner-packaging` gekapselt.
|
||||||
|
Dieses Modul hat genau eine Abhängigkeit: `pdf-umbenenner-bootstrap`.
|
||||||
|
|
||||||
|
### 2. Maven-Profil `release`
|
||||||
|
|
||||||
|
Das Profil `release` aktiviert ausschließlich den EXE-Build via `jpackage`.
|
||||||
|
Der normale Build (`mvn clean verify`) bleibt vom Profil vollständig unberührt.
|
||||||
|
WiX Toolset wird nur im Profil `release` benötigt.
|
||||||
|
|
||||||
|
### 3. Keine Modifikation bestehender Module
|
||||||
|
|
||||||
|
Bestehende Module (`domain`, `application`, `adapter-in-cli`, `adapter-in-gui`,
|
||||||
|
`adapter-out`, `bootstrap`) werden in M14 **nicht** verändert – weder POM noch
|
||||||
|
Produktions- noch Testcode.
|
||||||
|
|
||||||
|
### 4. Batch-Dateien
|
||||||
|
|
||||||
|
Die zwei Batch-Dateien landen als Ressourcen im Modul `pdf-umbenenner-packaging`
|
||||||
|
und werden durch das `jpackage`-Plugin in das EXE-Ausgabeverzeichnis kopiert.
|
||||||
|
|
||||||
|
| Dateiname | Funktion |
|
||||||
|
|---|---|
|
||||||
|
| `PDF-KI-Renamer.bat` | Headless-Modus (`--headless`) |
|
||||||
|
| `PDF-KI-Renamer-GUI.bat` | GUI-Modus (kein Argument) |
|
||||||
|
|
||||||
|
### 5. Dokumentation
|
||||||
|
|
||||||
|
`betrieb.md` wird am Ende von M14 aktualisiert: Der Abschnitt „Keine EXE, kein Installer"
|
||||||
|
wird durch eine korrekte Beschreibung des V2.5-Distributionsartefakts ersetzt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP-001 Neues Maven-Modul `pdf-umbenenner-packaging` anlegen
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
Kein. Dieses Arbeitspaket ist der M14-Startpunkt.
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
Die Projektstruktur wird um das Packaging-Modul erweitert, ohne den bestehenden Build zu berühren.
|
||||||
|
|
||||||
|
### Muss umgesetzt werden
|
||||||
|
- Modul `pdf-umbenenner-packaging` anlegen mit minimaler POM-Struktur.
|
||||||
|
- Modul in Parent-POM (`<modules>`) und Reactor aufnehmen.
|
||||||
|
- Abhängigkeit auf `pdf-umbenenner-bootstrap` (scope `runtime`) deklarieren.
|
||||||
|
- Das Modul erzeugt im Normalbuild (`mvn clean verify`) **kein** zusätzliches Artefakt.
|
||||||
|
- Keine Produktionsklassen, keine Tests – das Modul enthält ausschließlich
|
||||||
|
Maven-Konfiguration und Ressourcen.
|
||||||
|
- `package-info.java` entfällt (kein Java-Code im Modul).
|
||||||
|
|
||||||
|
### Explizit nicht Teil
|
||||||
|
- Plugin-Konfiguration für jpackage
|
||||||
|
- Maven-Profil `release`
|
||||||
|
- Batch-Dateien
|
||||||
|
- Icon
|
||||||
|
|
||||||
|
### Fertig wenn
|
||||||
|
- das neue Modul im Reactor vorhanden ist,
|
||||||
|
- `mvn clean verify` (ohne Profil) weiterhin fehlerfrei durchläuft,
|
||||||
|
- keine bestehenden Module verändert wurden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP-002 Ressourcen bereitstellen (Icon und Batch-Dateien)
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
AP-001 ist abgeschlossen.
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
Icon und Batch-Dateien liegen als versionierte Ressourcen im Modul bereit.
|
||||||
|
|
||||||
|
### Muss umgesetzt werden
|
||||||
|
|
||||||
|
**Icon:**
|
||||||
|
- Platzhalter-Icon `src/main/packaging/icon.ico` anlegen.
|
||||||
|
- Das Icon ist ein valides `.ico`-Format (1×1 Pixel genügt als Platzhalter).
|
||||||
|
- Kommentar in der Datei oder einer begleitenden `README-icon.md`:
|
||||||
|
„Platzhalter – vor dem Release durch echtes Icon ersetzen."
|
||||||
|
|
||||||
|
**Batch-Dateien** unter `src/main/packaging/`:
|
||||||
|
|
||||||
|
`PDF-KI-Renamer.bat`:
|
||||||
|
```bat
|
||||||
|
@echo off
|
||||||
|
"%~dp0PDF-KI-Renamer\PDF-KI-Renamer.exe" --headless %*
|
||||||
|
```
|
||||||
|
|
||||||
|
`PDF-KI-Renamer-GUI.bat`:
|
||||||
|
```bat
|
||||||
|
@echo off
|
||||||
|
"%~dp0PDF-KI-Renamer\PDF-KI-Renamer.exe" %*
|
||||||
|
```
|
||||||
|
|
||||||
|
- `%~dp0` stellt sicher, dass die EXE relativ zur Batch-Datei gefunden wird,
|
||||||
|
unabhängig vom aktuellen Arbeitsverzeichnis.
|
||||||
|
- `%*` leitet alle weiteren Argumente (z. B. `--config`) durch.
|
||||||
|
- Pfade mit Leerzeichen (z. B. `C:\Program Files\...`) sind durch die Anführungszeichen korrekt gequotet.
|
||||||
|
|
||||||
|
### Explizit nicht Teil
|
||||||
|
- Plugin-Konfiguration
|
||||||
|
- Kopieren der Batch-Dateien in das Ausgabeverzeichnis (folgt in AP-003)
|
||||||
|
|
||||||
|
### Fertig wenn
|
||||||
|
- Icon und beide Batch-Dateien unter `src/main/packaging/` vorhanden sind,
|
||||||
|
- `mvn clean verify` weiterhin fehlerfrei durchläuft.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP-003 Maven-Profil `release` mit jpackage konfigurieren
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
AP-002 ist abgeschlossen.
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
`mvn clean package -P release` erzeugt auf der Windows-Entwicklungsmaschine
|
||||||
|
(mit WiX Toolset im PATH) eine lauffähige Windows-EXE unter
|
||||||
|
`pdf-umbenenner-packaging/target/dist/`.
|
||||||
|
|
||||||
|
### Technischer Hintergrund
|
||||||
|
|
||||||
|
Das Projekt verwendet ein **nicht-modulares Fat-JAR** (Shade-Plugin, kein JPMS).
|
||||||
|
JavaFX-DLLs sind bereits im Shade-JAR enthalten (Windows-Classifier).
|
||||||
|
Die Main-Class erweitert bewusst nicht `javafx.application.Application`
|
||||||
|
(JavaFX-Launcher-Check-Workaround, dokumentiert in `betrieb.md`).
|
||||||
|
|
||||||
|
jpackage benötigt:
|
||||||
|
1. Das Shade-JAR als Eingabe (`--input` + `--main-jar`)
|
||||||
|
2. Eine minimale JRE (erzeugt via `jlink` oder automatisch durch jpackage)
|
||||||
|
3. WiX Toolset im PATH (für `--type exe`)
|
||||||
|
|
||||||
|
Da das Projekt nicht modular ist, muss jpackage mit `--add-modules ALL-MODULE-PATH`
|
||||||
|
oder einer expliziten Modulliste arbeiten. Die explizite Modulliste ist
|
||||||
|
wartungsfreundlicher und wird bevorzugt.
|
||||||
|
|
||||||
|
### Muss umgesetzt werden
|
||||||
|
|
||||||
|
**Maven-Profil `release`** in der POM von `pdf-umbenenner-packaging`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<profile>
|
||||||
|
<id>release</id>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<!-- Shade-JAR aus Bootstrap-Modul ins Packaging-Verzeichnis kopieren -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-dependency-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>copy-shade-jar</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals><goal>copy-dependencies</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<includeArtifactIds>pdf-umbenenner-bootstrap</includeArtifactIds>
|
||||||
|
<outputDirectory>${project.build.directory}/jpackage-input</outputDirectory>
|
||||||
|
<stripVersion>false</stripVersion>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<!-- jpackage -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.panteleyev</groupId>
|
||||||
|
<artifactId>jpackage-maven-plugin</artifactId>
|
||||||
|
<version>1.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>create-exe</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals><goal>jpackage</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<type>EXE</type>
|
||||||
|
<name>PDF-KI-Renamer</name>
|
||||||
|
<appVersion>${project.version}</appVersion>
|
||||||
|
<vendor>gecheckt.de</vendor>
|
||||||
|
<input>${project.build.directory}/jpackage-input</input>
|
||||||
|
<mainJar>pdf-umbenenner-bootstrap-${project.version}.jar</mainJar>
|
||||||
|
<mainClass>de.gecheckt.pdf.umbenenner.bootstrap.PdfUmbenennerApplication</mainClass>
|
||||||
|
<destination>${project.build.directory}/dist</destination>
|
||||||
|
<icon>${project.basedir}/src/main/packaging/icon.ico</icon>
|
||||||
|
<addModules>
|
||||||
|
java.base,java.desktop,java.logging,java.naming,java.net.http,
|
||||||
|
java.sql,java.xml,jdk.unsupported
|
||||||
|
</addModules>
|
||||||
|
<javaOptions>
|
||||||
|
<javaOption>-Xms64m</javaOption>
|
||||||
|
<javaOption>-Xmx512m</javaOption>
|
||||||
|
</javaOptions>
|
||||||
|
<winConsole>false</winConsole>
|
||||||
|
<winShortcut>false</winShortcut>
|
||||||
|
<winMenu>false</winMenu>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<!-- Batch-Dateien ins dist-Verzeichnis kopieren -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>copy-batch-files</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals><goal>copy-resources</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<outputDirectory>${project.build.directory}/dist</outputDirectory>
|
||||||
|
<resources>
|
||||||
|
<resource>
|
||||||
|
<directory>src/main/packaging</directory>
|
||||||
|
<includes>
|
||||||
|
<include>*.bat</include>
|
||||||
|
</includes>
|
||||||
|
</resource>
|
||||||
|
</resources>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</profile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtige Hinweise für Claude Code:**
|
||||||
|
- Die Modulliste (`addModules`) ist ein Ausgangspunkt. Der tatsächliche Bedarf
|
||||||
|
kann per `jdeps --print-module-deps` auf dem Shade-JAR ermittelt werden.
|
||||||
|
Claude Code soll `jdeps` ausführen und die Modulliste anpassen.
|
||||||
|
- `winConsole=false` sorgt dafür, dass kein CMD-Fenster beim GUI-Start erscheint.
|
||||||
|
Für den headless-Start via Batch ist das akzeptabel (Ausgabe geht in Log-Dateien).
|
||||||
|
- Die Plugin-Version `1.6.0` von `org.panteleyev:jpackage-maven-plugin` ist
|
||||||
|
zu verifizieren – aktuelle Version per Maven Central prüfen.
|
||||||
|
- Das `jpackage`-Plugin muss in `pluginManagement` im Parent-POM oder direkt
|
||||||
|
in der Packaging-POM versioniert sein.
|
||||||
|
|
||||||
|
### Explizit nicht Teil
|
||||||
|
- Anpassung von `betrieb.md` (folgt in AP-004)
|
||||||
|
- Manuelle Ausführung oder Smoke-Test
|
||||||
|
|
||||||
|
### Fertig wenn
|
||||||
|
- `mvn clean verify` (ohne Profil) weiterhin fehlerfrei durchläuft,
|
||||||
|
- die POM-Konfiguration syntaktisch korrekt und vollständig ist,
|
||||||
|
- `jdeps` auf dem Shade-JAR ausgeführt wurde und die Modulliste korrekt befüllt ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP-004 Dokumentation aktualisieren
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
AP-003 ist abgeschlossen.
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
Die Projektdokumentation spiegelt den V2.5-Stand korrekt wider.
|
||||||
|
|
||||||
|
### Muss umgesetzt werden
|
||||||
|
|
||||||
|
**`betrieb.md` – Abschnitt „Keine EXE, kein Installer" ersetzen durch:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Windows-EXE (V2.5)
|
||||||
|
|
||||||
|
Ab V2.5 steht neben dem Shade-JAR ein zweites Distributionsartefakt bereit:
|
||||||
|
eine **native Windows-EXE** für Windows 10/11 (x64) und Windows Server 2022 (x64).
|
||||||
|
|
||||||
|
Die EXE enthält eine eingebettete JRE 21 und benötigt keine separate Java-Installation
|
||||||
|
auf dem Zielsystem.
|
||||||
|
|
||||||
|
**Voraussetzungen für den EXE-Build (nur auf der Entwicklungsmaschine):**
|
||||||
|
- Windows x64
|
||||||
|
- JDK 21 im PATH
|
||||||
|
- [WiX Toolset 3.x](https://wixtoolset.org/) im PATH
|
||||||
|
|
||||||
|
**EXE bauen:**
|
||||||
|
```powershell
|
||||||
|
.\mvnw.cmd clean package -P release -pl pdf-umbenenner-packaging --also-make -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Ergebnis liegt unter:
|
||||||
|
```
|
||||||
|
pdf-umbenenner-packaging/target/dist/
|
||||||
|
PDF-KI-Renamer/ ← Anwendungsverzeichnis mit EXE und eingebetteter JRE
|
||||||
|
PDF-KI-Renamer.bat ← Headless-Start
|
||||||
|
PDF-KI-Renamer-GUI.bat ← GUI-Start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Die EXE ist nicht signiert. Beim ersten Start auf einem neuen System
|
||||||
|
erscheint eine Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
|
||||||
|
bestätigt werden muss.
|
||||||
|
```
|
||||||
|
|
||||||
|
**`betrieb.md` – Abschnitt „Voraussetzungen" aktualisieren:**
|
||||||
|
- Java 21 ist für Endnutzer der EXE **nicht** mehr erforderlich (eingebettet).
|
||||||
|
- Hinweis ergänzen: „Bei Verwendung des Shade-JAR direkt: Java 21 JRE erforderlich."
|
||||||
|
|
||||||
|
**`CLAUDE.md` aktualisieren** (falls vorhanden):
|
||||||
|
- Hinweis auf Profil `release` und WiX-Abhängigkeit ergänzen.
|
||||||
|
- Build-Kommando für EXE dokumentieren.
|
||||||
|
|
||||||
|
### Fertig wenn
|
||||||
|
- `betrieb.md` den neuen Abschnitt enthält,
|
||||||
|
- die Voraussetzungen korrekt aktualisiert sind,
|
||||||
|
- `mvn clean verify` weiterhin fehlerfrei durchläuft.
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# M15 - Arbeitspakete
|
||||||
|
|
||||||
|
## Geltungsbereich
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt ausschließlich die Arbeitspakete für den definierten Meilenstein
|
||||||
|
**M15 – MSI-Installer (V3.0)**.
|
||||||
|
|
||||||
|
Der Stand **V2.5** (M14 abgeschlossen) wird als vollständig umgesetzt vorausgesetzt:
|
||||||
|
- Modul `pdf-umbenenner-packaging` existiert
|
||||||
|
- Maven-Profil `release` ist konfiguriert
|
||||||
|
- `icon.ico`, `PDF-KI-Renamer.bat`, `PDF-KI-Renamer-GUI.bat` liegen unter
|
||||||
|
`pdf-umbenenner-packaging/src/main/packaging/`
|
||||||
|
|
||||||
|
Die Arbeitspakete sind so geschnitten, dass Opus 4.7 sie in einem Durchgang
|
||||||
|
vollständig umsetzen kann. Nach jedem Arbeitspaket muss `mvn clean verify`
|
||||||
|
(ohne Profil) fehlerfrei durchlaufen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielbild von M15
|
||||||
|
|
||||||
|
Nach Abschluss von M15 erzeugt `mvn clean package -P release` einen vollständigen
|
||||||
|
**MSI-Installer** (`PDF-KI-Renamer-2.5.0.msi`) der:
|
||||||
|
|
||||||
|
- die Anwendung nach `C:\Program Files\PDF KI Renamer\` installiert,
|
||||||
|
- eine Beispiel-Konfiguration nach
|
||||||
|
`C:\ProgramData\PDF KI Renamer\config\application.example.properties` ablegt,
|
||||||
|
- beide Batch-Dateien ins Installationsverzeichnis legt,
|
||||||
|
- einen Startmenü-Eintrag für den GUI-Start erstellt,
|
||||||
|
- einen Desktop-Shortcut erstellt,
|
||||||
|
- über „Programme und Features" sauber deinstallierbar ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abgrenzungen
|
||||||
|
|
||||||
|
### Explizit nicht Bestandteil von M15
|
||||||
|
|
||||||
|
- Automatische Konfigurationsauflösung aus `ProgramData` (bleibt `--config`-Sache)
|
||||||
|
- Code-Signing des MSI
|
||||||
|
- Upgrade-Logik (MajorUpgrade, automatisches Deinstallieren alter Versionen)
|
||||||
|
- Änderungen an fachlicher Logik, GUI, headless-Betrieb oder Persistenz
|
||||||
|
- Neue Tests
|
||||||
|
|
||||||
|
### Unveränderte Leitplanken
|
||||||
|
|
||||||
|
- `--type MSI` ersetzt `--type EXE` im Profil `release`
|
||||||
|
- Der Normalbuild (`mvn clean verify`) bleibt unverändert
|
||||||
|
- Bestehende Module außer `pdf-umbenenner-packaging` werden nicht angefasst
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbindliche M15-Regeln
|
||||||
|
|
||||||
|
### 1. Installationsverzeichnis
|
||||||
|
`C:\Program Files\PDF KI Renamer\`
|
||||||
|
|
||||||
|
### 2. Konfigurationsverzeichnis
|
||||||
|
`C:\ProgramData\PDF KI Renamer\config\`
|
||||||
|
|
||||||
|
Die Beispiel-Config wird aus `docs/examples/application.properties` des Projekts
|
||||||
|
in dieses Verzeichnis kopiert und als `application.example.properties` abgelegt.
|
||||||
|
|
||||||
|
### 3. Batch-Dateien
|
||||||
|
Beide Batch-Dateien landen im Installationsverzeichnis.
|
||||||
|
Die Pfade in den Batch-Dateien müssen auf das Installationsverzeichnis angepasst werden
|
||||||
|
(nicht mehr relativ per `%~dp0`, sondern absolut via Installationspfad-Variable oder
|
||||||
|
weiterhin relativ – beides ist akzeptabel solange es funktioniert).
|
||||||
|
|
||||||
|
### 4. Startmenü & Desktop
|
||||||
|
- Startmenü-Gruppe: `PDF KI Renamer`
|
||||||
|
- Startmenü-Eintrag: `PDF KI Renamer` → startet GUI
|
||||||
|
- Desktop-Shortcut: `PDF KI Renamer` → startet GUI
|
||||||
|
|
||||||
|
### 5. Deinstallation
|
||||||
|
Saubere Deinstallation über „Programme und Features". Vom Installer angelegte
|
||||||
|
Dateien werden entfernt. Nutzerdaten in `ProgramData` (Konfiguration, Logs, DB)
|
||||||
|
werden **nicht** gelöscht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP-001 MSI-Typ und Installer-Ressourcen vorbereiten
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
M14 ist abgeschlossen. `mvn clean verify` ist grün.
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
Das Profil `release` erzeugt einen MSI statt einer EXE,
|
||||||
|
und alle notwendigen Installer-Ressourcen liegen bereit.
|
||||||
|
|
||||||
|
### Muss umgesetzt werden
|
||||||
|
|
||||||
|
1. In `pdf-umbenenner-packaging/pom.xml` im Profil `release`:
|
||||||
|
- `<type>EXE</type>` → `<type>MSI</type>`
|
||||||
|
- Folgende Windows-spezifische jpackage-Optionen ergänzen:
|
||||||
|
```xml
|
||||||
|
<winShortcut>true</winShortcut>
|
||||||
|
<winMenu>true</winMenu>
|
||||||
|
<winMenuGroup>PDF KI Renamer</winMenuGroup>
|
||||||
|
<winDirChooser>true</winDirChooser>
|
||||||
|
<winShortcutPrompt>false</winShortcutPrompt>
|
||||||
|
<installDir>PDF KI Renamer</installDir>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Beispiel-Konfiguration als Installer-Ressource bereitstellen:
|
||||||
|
- `docs/examples/application.properties` nach
|
||||||
|
`pdf-umbenenner-packaging/src/main/packaging/application.example.properties`
|
||||||
|
kopieren (als versionierte Kopie im Modul – nicht das Original verschieben).
|
||||||
|
|
||||||
|
3. `mvn clean verify` muss weiterhin grün bleiben.
|
||||||
|
|
||||||
|
### Fertig wenn
|
||||||
|
- `<type>MSI</type>` in der POM gesetzt
|
||||||
|
- Windows-Optionen konfiguriert
|
||||||
|
- `application.example.properties` unter `src/main/packaging/` vorhanden
|
||||||
|
- `mvn clean verify` grün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP-002 ProgramData-Verzeichnis und Beispiel-Config im Installer verankern
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
AP-001 ist abgeschlossen.
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
Der MSI-Installer legt beim Installieren die Beispiel-Config unter
|
||||||
|
`C:\ProgramData\PDF KI Renamer\config\application.example.properties` ab.
|
||||||
|
|
||||||
|
### Technischer Hintergrund
|
||||||
|
|
||||||
|
jpackage unterstützt `--app-content` zum Hinzufügen zusätzlicher Dateien
|
||||||
|
in das Anwendungs-Image. Diese landen jedoch im Installationsverzeichnis,
|
||||||
|
nicht in `ProgramData`.
|
||||||
|
|
||||||
|
Für `ProgramData` gibt es zwei Wege:
|
||||||
|
- **Weg A**: jpackage `--resource-dir` mit WiX-Override (komplex, fehleranfällig)
|
||||||
|
- **Weg B**: Die Beispiel-Config über `--app-content` ins Installationsverzeichnis
|
||||||
|
legen und in der Dokumentation beschreiben, dass der Nutzer sie nach
|
||||||
|
`ProgramData` kopieren soll (einfach, robust)
|
||||||
|
|
||||||
|
**Verbindlich für M15: Weg B.**
|
||||||
|
|
||||||
|
### Muss umgesetzt werden
|
||||||
|
|
||||||
|
1. `application.example.properties` via `--app-content` in das
|
||||||
|
Anwendungsverzeichnis einbinden:
|
||||||
|
```xml
|
||||||
|
<appContent>
|
||||||
|
<appContent>src/main/packaging/application.example.properties</appContent>
|
||||||
|
</appContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `mvn clean verify` muss weiterhin grün bleiben.
|
||||||
|
|
||||||
|
### Fertig wenn
|
||||||
|
- `application.example.properties` ist in der jpackage-Konfiguration als
|
||||||
|
`appContent` eingebunden
|
||||||
|
- `mvn clean verify` grün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP-003 Desktop-Shortcut konfigurieren
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
AP-002 ist abgeschlossen.
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
Der Installer erstellt zusätzlich einen Desktop-Shortcut.
|
||||||
|
|
||||||
|
### Technischer Hintergrund
|
||||||
|
|
||||||
|
jpackage unterstützt Desktop-Shortcuts über `--win-shortcut`.
|
||||||
|
`<winShortcut>true</winShortcut>` ist bereits in AP-001 gesetzt –
|
||||||
|
das erzeugt jedoch primär einen Startmenü-Eintrag.
|
||||||
|
|
||||||
|
Für einen **Desktop**-Shortcut ist ein zusätzlicher WiX-Override nötig.
|
||||||
|
Prüfe zunächst ob `<winShortcut>true</winShortcut>` in Kombination mit
|
||||||
|
`<winShortcutPrompt>false</winShortcutPrompt>` bereits einen Desktop-Shortcut erzeugt.
|
||||||
|
Falls nicht, dokumentiere dies als bekannte Einschränkung in `betrieb.md`
|
||||||
|
und überspringe den WiX-Override (zu komplex für M15).
|
||||||
|
|
||||||
|
### Fertig wenn
|
||||||
|
- Entweder Desktop-Shortcut funktioniert, oder
|
||||||
|
- die Einschränkung ist in `betrieb.md` dokumentiert
|
||||||
|
- `mvn clean verify` grün
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AP-004 Dokumentation aktualisieren
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
AP-001 bis AP-003 sind abgeschlossen.
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
Die Projektdokumentation spiegelt den V3.0-Stand korrekt wider.
|
||||||
|
|
||||||
|
### Muss umgesetzt werden
|
||||||
|
|
||||||
|
1. `docs/betrieb.md` – Abschnitt „Windows-EXE (V2.5)" erweitern zu
|
||||||
|
„Windows-Installer (V3.0)":
|
||||||
|
- MSI-Build-Kommando dokumentieren
|
||||||
|
- Installationsverzeichnis dokumentieren
|
||||||
|
- Hinweis: Beispiel-Config liegt nach Installation im Installationsverzeichnis,
|
||||||
|
muss manuell nach `C:\ProgramData\PDF KI Renamer\config\` kopiert und
|
||||||
|
angepasst werden
|
||||||
|
- Hinweis auf SmartScreen-Warnung (kein Code-Signing)
|
||||||
|
- Headless-Betrieb: Beispiel-Aufruf mit `--config`
|
||||||
|
|
||||||
|
2. `CLAUDE.md` aktualisieren:
|
||||||
|
- Build-Kommando für MSI ergänzen
|
||||||
|
|
||||||
|
### Fertig wenn
|
||||||
|
- `betrieb.md` vollständig aktualisiert
|
||||||
|
- `CLAUDE.md` aktualisiert
|
||||||
|
- `mvn clean verify` grün
|
||||||
|
- M15 vollständig abgeschlossen
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>de.gecheckt</groupId>
|
<groupId>de.gecheckt</groupId>
|
||||||
<artifactId>pdf-umbenenner-parent</artifactId>
|
<artifactId>pdf-umbenenner-parent</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>${revision}</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>pdf-umbenenner-adapter-in-cli</artifactId>
|
<artifactId>pdf-umbenenner-adapter-in-cli</artifactId>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>de.gecheckt</groupId>
|
<groupId>de.gecheckt</groupId>
|
||||||
<artifactId>pdf-umbenenner-parent</artifactId>
|
<artifactId>pdf-umbenenner-parent</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>${revision}</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>pdf-umbenenner-adapter-in-gui</artifactId>
|
<artifactId>pdf-umbenenner-adapter-in-gui</artifactId>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
@@ -39,6 +39,31 @@
|
|||||||
<artifactId>javafx-controls</artifactId>
|
<artifactId>javafx-controls</artifactId>
|
||||||
<classifier>win</classifier>
|
<classifier>win</classifier>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- JavaFX-Swing-Interop: wird für SwingFXUtils.toFXImage (BufferedImage -> FX Image) benötigt -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-swing</artifactId>
|
||||||
|
<version>21.0.2</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PDF-Vorschau: PDFBox für direktes Rendering einzelner Seiten in BufferedImages -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.pdfbox</groupId>
|
||||||
|
<artifactId>pdfbox</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- JBIG2-Codec für PDF-Bilddecodierung -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.pdfbox</groupId>
|
||||||
|
<artifactId>jbig2-imageio</artifactId>
|
||||||
|
<version>3.0.4</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- JPEG2000-Codec für erweiterte PDF-Bilddecodierung -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.jai-imageio</groupId>
|
||||||
|
<artifactId>jai-imageio-jpeg2000</artifactId>
|
||||||
|
<version>1.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Logging -->
|
<!-- Logging -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -47,6 +72,19 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Test dependencies -->
|
<!-- Test dependencies -->
|
||||||
|
<!--
|
||||||
|
log4j-core on the test classpath provides the logging implementation for
|
||||||
|
tests that instantiate production classes using LogManager.getLogger.
|
||||||
|
Without it, Log4j2 falls back to SimpleLogger during test execution and
|
||||||
|
prints "Log4j2 could not find a logging implementation" at test start.
|
||||||
|
The production classpath is unaffected; log4j-core is supplied by the
|
||||||
|
bootstrap module in the shaded runtime JAR.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.logging.log4j</groupId>
|
||||||
|
<artifactId>log4j-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
<artifactId>junit-jupiter</artifactId>
|
<artifactId>junit-jupiter</artifactId>
|
||||||
@@ -62,6 +100,11 @@
|
|||||||
<artifactId>mockito-junit-jupiter</artifactId>
|
<artifactId>mockito-junit-jupiter</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.assertj</groupId>
|
||||||
|
<artifactId>assertj-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<!--
|
<!--
|
||||||
Monocle: headless JavaFX platform for GUI smoke tests.
|
Monocle: headless JavaFX platform for GUI smoke tests.
|
||||||
Provides the Glass platform implementation that runs JavaFX without a
|
Provides the Glass platform implementation that runs JavaFX without a
|
||||||
@@ -87,8 +130,8 @@
|
|||||||
prism.order=sw enables software rendering (no GPU required);
|
prism.order=sw enables software rendering (no GPU required);
|
||||||
prism.text=t2k selects the T2K text rasterizer (headless-safe);
|
prism.text=t2k selects the T2K text rasterizer (headless-safe);
|
||||||
java.awt.headless=true signals headless mode to AWT/Swing interop layers.
|
java.awt.headless=true signals headless mode to AWT/Swing interop layers.
|
||||||
The add-opens args are required for JavaFX internal access patterns used
|
Note: module-opening arguments for javafx.graphics are no longer required.
|
||||||
by Monocle and the Platform.startup API in Java 21 module context.
|
Modern JavaFX (21.x) with Monocle on Java 21 works without explicit module opening.
|
||||||
-->
|
-->
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
@@ -101,8 +144,6 @@
|
|||||||
-Dprism.order=sw
|
-Dprism.order=sw
|
||||||
-Dprism.text=t2k
|
-Dprism.text=t2k
|
||||||
-Djava.awt.headless=true
|
-Djava.awt.headless=true
|
||||||
--add-opens=javafx.graphics/com.sun.javafx.application=ALL-UNNAMED
|
|
||||||
--add-opens=javafx.graphics/com.sun.glass.ui=ALL-UNNAMED
|
|
||||||
</argLine>
|
</argLine>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
@@ -164,11 +205,13 @@
|
|||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<!--
|
<!--
|
||||||
GUI adapter mutation thresholds are intentionally low: the JavaFX
|
GUI adapter: PIT is skipped entirely. The JavaFX Application lifecycle
|
||||||
Application lifecycle requires a display or headless Monocle runtime
|
cannot be meaningfully mutation-tested without a running display or
|
||||||
which is introduced in a later work package. Once Monocle smoke tests
|
Monocle runtime, and the remaining testable surface is too small to
|
||||||
are in place, these thresholds will be raised.
|
produce useful mutation scores. Mutation analysis is deferred until
|
||||||
|
GUI coverage matures.
|
||||||
-->
|
-->
|
||||||
|
<skip>true</skip>
|
||||||
<coverageThreshold>0</coverageThreshold>
|
<coverageThreshold>0</coverageThreshold>
|
||||||
<mutationThreshold>0</mutationThreshold>
|
<mutationThreshold>0</mutationThreshold>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javafx.stage.FileChooser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Funktionales Interface fuer den Datei-Auswaehldialog der GUI.
|
||||||
|
* <p>
|
||||||
|
* Kapselt die Abhaengigkeit zum nativen {@link FileChooser} in einem
|
||||||
|
* injizierbaren Hook, der in Tests durch eine einfache Lambda-Implementierung
|
||||||
|
* ersetzt werden kann. Die Standardimplementierung oeffnet einen echten
|
||||||
|
* nativen Datei-Dialog; Test-Stubs koennen einen festen Pfad zurueckgeben
|
||||||
|
* oder {@code null} simulieren (Abbrechen).
|
||||||
|
* <p>
|
||||||
|
* Im Gegensatz zur frueheren {@code BiFunction}-Variante nimmt dieser Hook
|
||||||
|
* auch die Liste der {@link FileChooser.ExtensionFilter} entgegen, damit der
|
||||||
|
* native Dialog die Filter tatsaechlich anwenden kann.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
interface FilePickerDialog {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oeffnet den Datei-Auswaehldialog und gibt den ausgewaehlten absoluten
|
||||||
|
* Pfad zurueck.
|
||||||
|
*
|
||||||
|
* @param title der Titel des Dialogs
|
||||||
|
* @param initialPath der Anfangspfad als Hinweis; darf leer oder {@code null} sein
|
||||||
|
* @param filters Liste der Dateitypfilter; darf leer sein, aber nicht {@code null}
|
||||||
|
* @return der ausgewaehlte absolute Pfad als String, oder {@code null} wenn abgebrochen
|
||||||
|
*/
|
||||||
|
String pick(String title, String initialPath, List<FileChooser.ExtensionFilter> filters);
|
||||||
|
}
|
||||||
+2
-2
@@ -2,11 +2,11 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
|||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import javafx.application.Application;
|
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import javafx.application.Application;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry point for the JavaFX desktop GUI inbound adapter.
|
* Entry point for the JavaFX desktop GUI inbound adapter.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback invoked by the workspace on a background thread after a configuration file
|
||||||
|
* has been successfully loaded from disk.
|
||||||
|
* <p>
|
||||||
|
* Bootstrap supplies an implementation that builds the application run context
|
||||||
|
* (migrate → load → validate → schema-init sequence) and, on success, also initialises
|
||||||
|
* the automatic scheduler. The workspace calls this initializer inside the same
|
||||||
|
* background submit that loads the editor state, so the JavaFX Application Thread is
|
||||||
|
* never blocked.
|
||||||
|
* <p>
|
||||||
|
* In isolated GUI tests a {@link #noOp() no-op} implementation can be used so that no
|
||||||
|
* Bootstrap wiring is required.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiApplicationContextInitializer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to initialise the application run context for the supplied configuration file.
|
||||||
|
* <p>
|
||||||
|
* If context initialisation succeeds and the configuration enables the scheduler, the
|
||||||
|
* scheduler is also wired and its use case is returned in the result. The caller is
|
||||||
|
* responsible for handing the scheduler use case to the scheduler tab on the JavaFX
|
||||||
|
* Application Thread via {@code Platform.runLater}.
|
||||||
|
* <p>
|
||||||
|
* This method must be called on a background worker thread, not on the JavaFX Application
|
||||||
|
* Thread.
|
||||||
|
*
|
||||||
|
* @param configFilePath path to the {@code .properties} configuration file; must exist on disk
|
||||||
|
* @return the result of the initialisation attempt; never {@code null}
|
||||||
|
*/
|
||||||
|
InitResult initialize(Path configFilePath);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a no-op initializer that always reports success and no scheduler.
|
||||||
|
* <p>
|
||||||
|
* Suitable for GUI tests and startup paths where no Bootstrap wiring is available.
|
||||||
|
*
|
||||||
|
* @return no-op initializer; never {@code null}
|
||||||
|
*/
|
||||||
|
static GuiApplicationContextInitializer noOp() {
|
||||||
|
return configFilePath -> new InitResult(Optional.empty(), Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a context initialisation attempt.
|
||||||
|
*
|
||||||
|
* @param contextError empty on success; a human-readable German error message
|
||||||
|
* when initialisation failed — the GUI remains functional
|
||||||
|
* but falls back to per-run initialisation for batch runs;
|
||||||
|
* must not be {@code null}
|
||||||
|
* @param schedulerControlUseCase the scheduler use case when the configuration enables the
|
||||||
|
* scheduler and initialisation succeeded; empty otherwise;
|
||||||
|
* must not be {@code null}
|
||||||
|
*/
|
||||||
|
record InitResult(
|
||||||
|
Optional<String> contextError,
|
||||||
|
Optional<SchedulerControlUseCase> schedulerControlUseCase) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+2380
-190
File diff suppressed because it is too large
Load Diff
+5
@@ -6,6 +6,11 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
|||||||
public class GuiConfigurationLoadException extends RuntimeException {
|
public class GuiConfigurationLoadException extends RuntimeException {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private static final long serialVersionUID = 5039061738684738963L;
|
||||||
|
|
||||||
|
/**
|
||||||
* Creates a new load exception.
|
* Creates a new load exception.
|
||||||
*
|
*
|
||||||
* @param message the exception message
|
* @param message the exception message
|
||||||
|
|||||||
+5
@@ -9,6 +9,11 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
|||||||
public class GuiConfigurationWriteException extends RuntimeException {
|
public class GuiConfigurationWriteException extends RuntimeException {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private static final long serialVersionUID = -6970750036865888915L;
|
||||||
|
|
||||||
|
/**
|
||||||
* Creates an exception with the given message.
|
* Creates an exception with the given message.
|
||||||
*
|
*
|
||||||
* @param message the error description; must not be {@code null}
|
* @param message the error description; must not be {@code null}
|
||||||
|
|||||||
+287
@@ -0,0 +1,287 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionReport;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Koordiniert den gesammelten Bestätigungsdialog und die anschließende Ausführung
|
||||||
|
* schreibender Korrekturmaßnahmen nach einem technischen Gesamttest.
|
||||||
|
* <p>
|
||||||
|
* Der Koordinator empfängt einen {@link TechnicalTestReport}, prüft ob korrigierbare
|
||||||
|
* Befunde vorliegen, leitet daraus einen {@link CorrectionPlan} ab, zeigt dem Benutzer
|
||||||
|
* einen gesammelten Bestätigungsdialog und führt die Korrekturen bei Bestätigung über
|
||||||
|
* den {@link CorrectionExecutionService} auf einem Hintergrund-Worker-Thread aus.
|
||||||
|
* Ergebnisse werden in die geteilte {@code pendingMessages}-Liste eingehängt.
|
||||||
|
*
|
||||||
|
* <h2>Ablauf</h2>
|
||||||
|
* <ol>
|
||||||
|
* <li>Bericht erhält: prüfen ob {@code hasCorrectableFindings()}.</li>
|
||||||
|
* <li>Wenn keine korrigierbaren Befunde: kein Dialog, keine Aktion.</li>
|
||||||
|
* <li>Wenn korrigierbare Befunde: {@link CorrectionPlan} ableiten.</li>
|
||||||
|
* <li>Dialog auf FX-Thread anzeigen.</li>
|
||||||
|
* <li>Bei Bestätigung: Korrekturen auf Worker-Thread ausführen.</li>
|
||||||
|
* <li>Ergebnisse via {@code Platform.runLater} auf FX-Thread zurückführen.</li>
|
||||||
|
* <li>Meldungen in {@code pendingMessages} einhängen (Replace-Semantik).</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <h2>Threading-Kontrakt</h2>
|
||||||
|
* <p>
|
||||||
|
* {@link #offerCorrections(TechnicalTestReport)} muss auf dem JavaFX Application Thread
|
||||||
|
* aufgerufen werden. I/O (Ausführung der Korrekturen) läuft auf einem dedizierten
|
||||||
|
* Daemon-Hintergrund-Thread. UI-Updates erfolgen ausschließlich via
|
||||||
|
* {@code Platform.runLater}.
|
||||||
|
*
|
||||||
|
* <h2>Keine stillen Korrekturen</h2>
|
||||||
|
* <p>
|
||||||
|
* Ohne ausdrückliche Benutzerbestätigung werden keine schreibenden Änderungen ausgeführt.
|
||||||
|
* Bei Dialog-Abbruch bleibt der Zustand unverändert.
|
||||||
|
*
|
||||||
|
* <h2>Anti-Scope</h2>
|
||||||
|
* <p>
|
||||||
|
* Dieser Koordinator führt keine Provider-nahen Korrekturen, keine Änderung fachlich
|
||||||
|
* riskanter Werte und keine automatischen Laufstarts durch.
|
||||||
|
*/
|
||||||
|
public final class GuiCorrectionDialogCoordinator {
|
||||||
|
|
||||||
|
/** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
|
||||||
|
static final String SOURCE_TAG = "Korrekturen";
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiCorrectionDialogCoordinator.class);
|
||||||
|
|
||||||
|
private final CorrectionExecutionService correctionExecutionService;
|
||||||
|
private final List<GuiMessageEntry> pendingMessages;
|
||||||
|
private final Consumer<Void> refreshCallback;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Funktion, die dem Benutzer den Bestätigungsdialog zeigt.
|
||||||
|
* <p>
|
||||||
|
* Erhält den {@link ConfirmationDialogContent} und gibt {@code true} zurück, wenn der
|
||||||
|
* Benutzer „Fortfahren" wählt, andernfalls {@code false}. Standard: echter Alert.
|
||||||
|
* Paket-privat für Test-Substitution.
|
||||||
|
*/
|
||||||
|
Function<ConfirmationDialogContent, Boolean> dialogSupplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory für den Hintergrund-Worker-Thread.
|
||||||
|
* Standard: Daemon-Thread namens {@code gui-correction-worker}.
|
||||||
|
* Paket-privat für Test-Substitution.
|
||||||
|
*/
|
||||||
|
Function<Runnable, Thread> correctionThreadFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbraucher zur Rückführung des Ergebnisses auf den FX-Thread.
|
||||||
|
* Standard: {@code Platform.runLater}. Paket-privat für Test-Substitution.
|
||||||
|
*/
|
||||||
|
java.util.function.Consumer<Runnable> resultDelivery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Koordinator.
|
||||||
|
*
|
||||||
|
* @param correctionExecutionService Service für die Ausführung von Korrekturen; darf nicht {@code null} sein
|
||||||
|
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
|
||||||
|
* @param refreshCallback Callback nach Anwendung der Ergebnisse (z. B. View-Aktualisierung);
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public GuiCorrectionDialogCoordinator(CorrectionExecutionService correctionExecutionService,
|
||||||
|
List<GuiMessageEntry> pendingMessages,
|
||||||
|
Consumer<Void> refreshCallback) {
|
||||||
|
this.correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
|
||||||
|
"correctionExecutionService must not be null");
|
||||||
|
this.pendingMessages = Objects.requireNonNull(pendingMessages,
|
||||||
|
"pendingMessages must not be null");
|
||||||
|
this.refreshCallback = Objects.requireNonNull(refreshCallback,
|
||||||
|
"refreshCallback must not be null");
|
||||||
|
|
||||||
|
this.dialogSupplier = this::showConfirmationDialog;
|
||||||
|
this.correctionThreadFactory = task -> {
|
||||||
|
Thread t = new Thread(task, "gui-correction-worker");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
this.resultDelivery = Platform::runLater;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Bericht auf korrigierbare Befunde, zeigt bei Bedarf den Bestätigungsdialog
|
||||||
|
* und führt die Korrekturen nach Bestätigung asynchron aus.
|
||||||
|
* <p>
|
||||||
|
* Wenn der Bericht keine korrigierbaren Befunde enthält ({@code hasCorrectableFindings()
|
||||||
|
* == false}), wird kein Dialog angezeigt und keine Aktion ausgeführt.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public void offerCorrections(TechnicalTestReport report) {
|
||||||
|
Objects.requireNonNull(report, "report must not be null");
|
||||||
|
|
||||||
|
if (!report.hasCorrectableFindings()) {
|
||||||
|
LOG.debug("Gesamttest: Keine korrigierbaren Befunde – kein Bestätigungsdialog.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CorrectionPlan plan = report.deriveCorrectionPlan();
|
||||||
|
if (!plan.hasCorrections()) {
|
||||||
|
LOG.debug("Gesamttest: Korrekturplan ist leer – kein Bestätigungsdialog.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.info("Gesamttest: {} korrigierbare Befunde. Bestätigungsdialog wird angezeigt.", plan.size());
|
||||||
|
|
||||||
|
ConfirmationDialogContent dialogContent = ConfirmationDialogContent.fromPlan(plan);
|
||||||
|
boolean confirmed = dialogSupplier.apply(dialogContent);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen abgelehnt. Keine Änderungen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.info("Bestätigungsdialog: Benutzer hat Korrekturen bestätigt. Ausführung startet.");
|
||||||
|
Runnable task = () -> {
|
||||||
|
CorrectionExecutionReport executionReport = correctionExecutionService.execute(plan);
|
||||||
|
resultDelivery.accept(() -> {
|
||||||
|
applyResult(executionReport);
|
||||||
|
refreshCallback.accept(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Thread worker = correctionThreadFactory.apply(task);
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet das Ergebnis der Korrekturausführung auf die geteilte Nachrichtenliste an.
|
||||||
|
* <p>
|
||||||
|
* Entfernt alle vorherigen Einträge mit Quelle {@link #SOURCE_TAG} und fügt für jedes
|
||||||
|
* {@link CorrectionOutcome} einen neuen Eintrag hinzu. Zusätzlich wird eine Zusammenfassung
|
||||||
|
* angehängt.
|
||||||
|
* <p>
|
||||||
|
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
|
||||||
|
*
|
||||||
|
* @param report das Ausführungsergebnis; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
private void applyResult(CorrectionExecutionReport report) {
|
||||||
|
// Alte Einträge mit Source-Tag entfernen (Replace-Semantik)
|
||||||
|
pendingMessages.removeIf(msg -> SOURCE_TAG.equals(msg.source().orElse("")));
|
||||||
|
|
||||||
|
long appliedCount = 0;
|
||||||
|
long failedCount = 0;
|
||||||
|
long notAttemptedCount = 0;
|
||||||
|
|
||||||
|
for (CorrectionOutcome outcome : report.outcomes()) {
|
||||||
|
switch (outcome) {
|
||||||
|
case CorrectionOutcome.Applied applied -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.INFO,
|
||||||
|
"Korrektur angewendet: " + applied.suggestion().descriptionForUser()
|
||||||
|
+ " – " + applied.message(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
appliedCount++;
|
||||||
|
LOG.info("Korrektur angewendet: {} – {}", applied.suggestion().descriptionForUser(),
|
||||||
|
applied.message());
|
||||||
|
}
|
||||||
|
case CorrectionOutcome.Failed failed -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.ERROR,
|
||||||
|
"Korrektur fehlgeschlagen: " + failed.suggestion().descriptionForUser()
|
||||||
|
+ " (" + failed.errorMessage() + ")",
|
||||||
|
SOURCE_TAG));
|
||||||
|
failedCount++;
|
||||||
|
LOG.warn("Korrektur fehlgeschlagen: {} – {}", failed.suggestion().descriptionForUser(),
|
||||||
|
failed.errorMessage());
|
||||||
|
}
|
||||||
|
case CorrectionOutcome.NotAttempted notAttempted -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.HINT,
|
||||||
|
"Korrektur nicht durchgeführt: " + notAttempted.suggestion().descriptionForUser()
|
||||||
|
+ " – " + notAttempted.reason(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
notAttemptedCount++;
|
||||||
|
LOG.info("Korrektur nicht durchgeführt: {} – {}", notAttempted.suggestion().descriptionForUser(),
|
||||||
|
notAttempted.reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusammenfassung
|
||||||
|
String summary = "Korrekturausführung abgeschlossen: "
|
||||||
|
+ appliedCount + " angewendet, "
|
||||||
|
+ failedCount + " fehlgeschlagen, "
|
||||||
|
+ notAttemptedCount + " nicht versucht.";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
|
||||||
|
LOG.info("Korrekturausführung abgeschlossen: {} angewendet, {} fehlgeschlagen, {} nicht versucht.",
|
||||||
|
appliedCount, failedCount, notAttemptedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt den echten JavaFX-Bestätigungsdialog und gibt die Benutzerentscheidung zurück.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden. Standard-Fokus liegt auf
|
||||||
|
* dem Abbrechen-Button (kein versehentliches Bestätigen durch Enter).
|
||||||
|
*
|
||||||
|
* @param content der Dialoginhalt; darf nicht {@code null} sein
|
||||||
|
* @return {@code true} wenn der Benutzer „Fortfahren" wählt, sonst {@code false}
|
||||||
|
*/
|
||||||
|
private boolean showConfirmationDialog(ConfirmationDialogContent content) {
|
||||||
|
javafx.scene.control.ButtonType proceedButton =
|
||||||
|
new javafx.scene.control.ButtonType("Fortfahren",
|
||||||
|
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
|
||||||
|
javafx.scene.control.ButtonType cancelButton =
|
||||||
|
new javafx.scene.control.ButtonType("Abbrechen",
|
||||||
|
javafx.scene.control.ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||||
|
|
||||||
|
javafx.scene.control.Alert alert = new javafx.scene.control.Alert(
|
||||||
|
javafx.scene.control.Alert.AlertType.CONFIRMATION);
|
||||||
|
alert.setTitle(content.title());
|
||||||
|
alert.setHeaderText(content.introText());
|
||||||
|
|
||||||
|
StringBuilder correctionListText = new StringBuilder();
|
||||||
|
for (String line : content.correctionLines()) {
|
||||||
|
correctionListText.append("• ").append(line).append("\n");
|
||||||
|
}
|
||||||
|
correctionListText.append("\nFortfahren?");
|
||||||
|
alert.setContentText(correctionListText.toString());
|
||||||
|
|
||||||
|
alert.getButtonTypes().setAll(proceedButton, cancelButton);
|
||||||
|
|
||||||
|
// Standard-Fokus: Abbrechen
|
||||||
|
javafx.scene.Node cancelNode = alert.getDialogPane().lookupButton(cancelButton);
|
||||||
|
if (cancelNode instanceof javafx.scene.control.Button btn) {
|
||||||
|
btn.setDefaultButton(true);
|
||||||
|
}
|
||||||
|
javafx.scene.Node proceedNode = alert.getDialogPane().lookupButton(proceedButton);
|
||||||
|
if (proceedNode instanceof javafx.scene.control.Button btn) {
|
||||||
|
btn.setDefaultButton(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
java.util.Optional<javafx.scene.control.ButtonType> result = alert.showAndWait();
|
||||||
|
return result.isPresent() && result.get() == proceedButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt eine unveränderliche Momentaufnahme der aktuell ausstehenden Nachrichten zurück.
|
||||||
|
* <p>
|
||||||
|
* Ausschließlich für Tests gedacht.
|
||||||
|
*
|
||||||
|
* @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
|
||||||
|
*/
|
||||||
|
public List<GuiMessageEntry> pendingMessagesSnapshot() {
|
||||||
|
return List.copyOf(pendingMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase.CreateNewDatabaseResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Workspace und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die DB-Anlage- und Wechsellogik
|
||||||
|
* für die GUI bereitstellt, ohne dass der GUI-Adapter direkt auf den Use-Case oder die
|
||||||
|
* darunterliegenden Outbound-Ports zugreift.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen dürfen blockierende Operationen
|
||||||
|
* ausführen (Flyway-Migration, Verbindungstest, atomares Verschieben einer Datei).
|
||||||
|
* Sie müssen daher von einem Hintergrund-Worker-Thread aufgerufen werden. Der Aufruf
|
||||||
|
* blockiert, bis das Ergebnis vollständig vorliegt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiCreateNewDatabasePort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und
|
||||||
|
* stellt die aktive Datenbankreferenz auf diese Datei um.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei,
|
||||||
|
* oder {@code null}, wenn (noch) keine Konfiguration
|
||||||
|
* geladen ist. Die Bootstrap-Implementierung leitet
|
||||||
|
* daraus den Pfad der aktuell aktiven SQLite-Datei ab,
|
||||||
|
* sofern noch kein Override vom
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}
|
||||||
|
* gesetzt ist.
|
||||||
|
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler;
|
||||||
|
* nie {@code null}
|
||||||
|
*/
|
||||||
|
CreateNewDatabaseResult createNewDatabase(Path configFilePath, Path targetFile);
|
||||||
|
}
|
||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates asynchronous model catalogue retrieval for the GUI provider section.
|
||||||
|
* <p>
|
||||||
|
* This coordinator is responsible for:
|
||||||
|
* <ul>
|
||||||
|
* <li>Triggering a background HTTP call via {@link AiModelCatalogPort} on a dedicated
|
||||||
|
* daemon thread named {@code gui-model-catalog}.</li>
|
||||||
|
* <li>Returning the result to the JavaFX Application Thread via {@code Platform.runLater}.</li>
|
||||||
|
* <li>Updating the per-provider {@link GuiModelFieldContainer} to show either a
|
||||||
|
* non-editable {@code ComboBox} (success) or a manual text field (all other cases).</li>
|
||||||
|
* <li>Appending a {@link GuiMessageEntry} to the supplied pending-messages list for each
|
||||||
|
* completed retrieval attempt, so later GUI layers can display the result.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Parallele Abrufanfragen (z. B. durch schnellen Provider-Wechsel oder mehrfaches Klicken
|
||||||
|
* auf „Modelle neu laden") werden durch einen Generationszähler entschärft: Jede neue Anfrage
|
||||||
|
* erhöht den Zähler. Wenn das Ergebnis eines Hintergrund-Threads auf dem JavaFX-Thread
|
||||||
|
* verarbeitet wird, prüft der Coordinator, ob die Generationsnummer noch aktuell ist. Veraltete
|
||||||
|
* Ergebnisse (aus einer früheren Anfrage) werden verworfen, sodass stets nur das Ergebnis der
|
||||||
|
* jüngsten Anfrage in die Meldungsliste und die Feldcontainer geschrieben wird.
|
||||||
|
* <p>
|
||||||
|
* The worker thread factory is injectable so tests can supply a synchronous or latch-guarded
|
||||||
|
* executor without spinning a real OS thread.
|
||||||
|
* <p>
|
||||||
|
* This class is not thread-safe by itself. All methods intended to mutate GUI state must be
|
||||||
|
* called on the JavaFX Application Thread. Background threads only interact through
|
||||||
|
* {@code Platform.runLater}.
|
||||||
|
*/
|
||||||
|
public final class GuiModelCatalogCoordinator {
|
||||||
|
private static final String LOG_MODEL_FETCH_FMT = "GUI-Modellabruf: {} (Provider: {})";
|
||||||
|
private static final String OPERATION_MODELLABRUF = "Modellabruf";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
|
||||||
|
|
||||||
|
/** Default timeout used when no timeout is configured in the provider state. */
|
||||||
|
static final int DEFAULT_TIMEOUT_SECONDS = 10;
|
||||||
|
|
||||||
|
private final AiModelCatalogPort modelCatalogPort;
|
||||||
|
private final List<GuiMessageEntry> pendingMessages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for the background worker thread. Package-private to allow test substitution.
|
||||||
|
* The default creates a daemon thread named {@code gui-model-catalog}.
|
||||||
|
*/
|
||||||
|
Function<Runnable, Thread> modelCatalogThreadFactory;
|
||||||
|
|
||||||
|
/** Per-provider field containers; populated by the workspace when it builds provider blocks. */
|
||||||
|
private final Map<AiProviderFamily, GuiModelFieldContainer> fieldContainers =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generationszähler zur Erkennung veralteter Abruf-Ergebnisse.
|
||||||
|
* Wird bei jeder neuen Anfrage in {@link #triggerModelRetrieval} atomar erhöht.
|
||||||
|
* Hintergrund-Threads erfassen die Generation beim Start; auf dem JavaFX-Thread wird
|
||||||
|
* das Ergebnis verworfen, wenn die gespeicherte Generation nicht mehr aktuell ist.
|
||||||
|
*/
|
||||||
|
private final AtomicLong retrievalGeneration = new AtomicLong(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumer that delivers the retrieval result. In production this wraps the call in
|
||||||
|
* {@code Platform.runLater}. In tests it can be replaced with a direct call so the result
|
||||||
|
* is applied immediately on the worker thread without needing an FX queue drain.
|
||||||
|
* Package-private to allow test substitution.
|
||||||
|
*/
|
||||||
|
java.util.function.Consumer<Runnable> resultDelivery = Platform::runLater;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional callback invoked on the JavaFX Application Thread after each retrieval result has
|
||||||
|
* been applied. The workspace uses this hook to refresh the central message area and field-error
|
||||||
|
* labels without coupling the coordinator to the workspace implementation.
|
||||||
|
* Package-private to allow substitution in tests.
|
||||||
|
*/
|
||||||
|
Runnable postResultCallback = () -> { };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a coordinator backed by the given catalogue port and shared message list.
|
||||||
|
*
|
||||||
|
* @param modelCatalogPort port used for background HTTP calls; must not be {@code null}
|
||||||
|
* @param pendingMessages mutable list to append result messages to; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public GuiModelCatalogCoordinator(AiModelCatalogPort modelCatalogPort,
|
||||||
|
List<GuiMessageEntry> pendingMessages) {
|
||||||
|
this.modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
|
||||||
|
"modelCatalogPort must not be null");
|
||||||
|
this.pendingMessages = Objects.requireNonNull(pendingMessages,
|
||||||
|
"pendingMessages must not be null");
|
||||||
|
this.modelCatalogThreadFactory = task -> {
|
||||||
|
Thread t = new Thread(task, "gui-model-catalog");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a {@link GuiModelFieldContainer} for the given provider family.
|
||||||
|
* <p>
|
||||||
|
* Must be called on the JavaFX Application Thread before the first retrieval is triggered.
|
||||||
|
*
|
||||||
|
* @param family the provider family this container belongs to; must not be {@code null}
|
||||||
|
* @param container the container to register; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public void registerFieldContainer(AiProviderFamily family, GuiModelFieldContainer container) {
|
||||||
|
Objects.requireNonNull(family, "family must not be null");
|
||||||
|
Objects.requireNonNull(container, "container must not be null");
|
||||||
|
fieldContainers.put(family, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers an asynchronous model catalogue retrieval for the given provider family.
|
||||||
|
* <p>
|
||||||
|
* The retrieval is performed on a background worker thread. The result is delivered back
|
||||||
|
* to the JavaFX Application Thread via {@code Platform.runLater}. The registered
|
||||||
|
* {@link GuiModelFieldContainer} for the provider is updated accordingly, and a
|
||||||
|
* {@link GuiMessageEntry} is appended to the pending-messages list.
|
||||||
|
* <p>
|
||||||
|
* If no field container is registered for the provider, the call is a no-op.
|
||||||
|
* <p>
|
||||||
|
* Must be called on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param family the provider family to retrieve models for; must not be {@code null}
|
||||||
|
* @param providerState the current editor state for the provider; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public void triggerModelRetrieval(AiProviderFamily family,
|
||||||
|
GuiProviderConfigurationState providerState) {
|
||||||
|
Objects.requireNonNull(family, "family must not be null");
|
||||||
|
Objects.requireNonNull(providerState, "providerState must not be null");
|
||||||
|
|
||||||
|
GuiModelFieldContainer container = fieldContainers.get(family);
|
||||||
|
if (container == null) {
|
||||||
|
LOG.debug("GUI-Modellabruf: Kein Feld-Container für Provider '{}' registriert – übersprungen.",
|
||||||
|
family.getIdentifier());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the current manual value before starting the background call.
|
||||||
|
String previousManualValue = container.currentModelValue();
|
||||||
|
|
||||||
|
// Build the request from the current editor state.
|
||||||
|
ModelCatalogRequest request = buildRequest(family, providerState);
|
||||||
|
|
||||||
|
// Generationsnummer erhöhen – laufende Hintergrund-Threads mit einer älteren
|
||||||
|
// Generationsnummer verwerfen ihr Ergebnis, sobald sie auf dem FX-Thread ankommen.
|
||||||
|
long currentGeneration = retrievalGeneration.incrementAndGet();
|
||||||
|
|
||||||
|
LOG.info("GUI-Modellabruf: Modelllistenabruf für Provider '{}' gestartet (Generation {}).",
|
||||||
|
family.getIdentifier(), currentGeneration);
|
||||||
|
|
||||||
|
Runnable task = () -> {
|
||||||
|
ModelCatalogResult result = modelCatalogPort.fetchAvailableModels(request);
|
||||||
|
resultDelivery.accept(() -> {
|
||||||
|
// Veraltetes Ergebnis verwerfen, wenn inzwischen eine neuere Anfrage gestartet wurde.
|
||||||
|
if (retrievalGeneration.get() != currentGeneration) {
|
||||||
|
LOG.debug("GUI-Modellabruf: Ergebnis für Provider '{}' verworfen"
|
||||||
|
+ " (Generation {} ist nicht mehr aktuell).",
|
||||||
|
family.getIdentifier(), currentGeneration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyResult(family, container, result, previousManualValue);
|
||||||
|
postResultCallback.run();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Thread worker = modelCatalogThreadFactory.apply(task);
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the result of a completed model catalogue retrieval to the field container and
|
||||||
|
* appends a message entry to the pending-messages list.
|
||||||
|
* <p>
|
||||||
|
* Must only be called on the JavaFX Application Thread (via {@code Platform.runLater}).
|
||||||
|
*
|
||||||
|
* @param family the provider family that was queried; must not be {@code null}
|
||||||
|
* @param container the field container to update; must not be {@code null}
|
||||||
|
* @param result the retrieval result; must not be {@code null}
|
||||||
|
* @param previousManualValue the model value that was in the text field before the call
|
||||||
|
*/
|
||||||
|
private void applyResult(AiProviderFamily family,
|
||||||
|
GuiModelFieldContainer container,
|
||||||
|
ModelCatalogResult result,
|
||||||
|
String previousManualValue) {
|
||||||
|
// Remove any previous message entries from an earlier retrieval so messages do not
|
||||||
|
// accumulate across repeated triggers of the same retrieval action.
|
||||||
|
pendingMessages.removeIf(msg -> OPERATION_MODELLABRUF.equals(msg.source().orElse("")));
|
||||||
|
|
||||||
|
String displayName = displayNameFor(family);
|
||||||
|
|
||||||
|
switch (result) {
|
||||||
|
case ModelCatalogResult.Success success -> {
|
||||||
|
List<String> models = success.models();
|
||||||
|
container.applyModelList(models, previousManualValue);
|
||||||
|
String message = "Modellliste für " + displayName + " geladen ("
|
||||||
|
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, OPERATION_MODELLABRUF));
|
||||||
|
LOG.info(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
|
||||||
|
}
|
||||||
|
case ModelCatalogResult.EmptyList emptyList -> {
|
||||||
|
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||||
|
String message = "Provider " + displayName
|
||||||
|
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, OPERATION_MODELLABRUF));
|
||||||
|
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
|
||||||
|
}
|
||||||
|
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
|
||||||
|
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||||
|
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
|
||||||
|
+ ". Manuelle Eingabe aktiv.";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, OPERATION_MODELLABRUF));
|
||||||
|
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
|
||||||
|
}
|
||||||
|
case ModelCatalogResult.TechnicalFailure failure -> {
|
||||||
|
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
|
||||||
|
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
|
||||||
|
+ "). Manuelle Eingabe aktiv.";
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, OPERATION_MODELLABRUF));
|
||||||
|
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
|
||||||
|
message, failure.errorDetail(), family.getIdentifier());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link ModelCatalogRequest} from the current provider editor state.
|
||||||
|
* <p>
|
||||||
|
* Missing or blank values are passed as {@code Optional.empty()} so the adapter can apply
|
||||||
|
* its own defaults or return {@link ModelCatalogResult.IncompleteConfiguration} if required
|
||||||
|
* values are absent.
|
||||||
|
*
|
||||||
|
* @param family the target provider family; must not be {@code null}
|
||||||
|
* @param providerState the current provider editor state; must not be {@code null}
|
||||||
|
* @return a new request; never {@code null}
|
||||||
|
*/
|
||||||
|
private static ModelCatalogRequest buildRequest(AiProviderFamily family,
|
||||||
|
GuiProviderConfigurationState providerState) {
|
||||||
|
Optional<String> baseUrl = Optional.ofNullable(providerState.baseUrl())
|
||||||
|
.filter(s -> !s.isBlank());
|
||||||
|
Optional<String> apiKey = Optional.ofNullable(providerState.apiKey())
|
||||||
|
.map(keyState -> keyState.propertyValue())
|
||||||
|
.filter(s -> !s.isBlank());
|
||||||
|
|
||||||
|
int timeout = DEFAULT_TIMEOUT_SECONDS;
|
||||||
|
String timeoutStr = providerState.timeoutSeconds();
|
||||||
|
if (timeoutStr != null && !timeoutStr.isBlank()) {
|
||||||
|
try {
|
||||||
|
int parsed = Integer.parseInt(timeoutStr.trim());
|
||||||
|
if (parsed > 0) {
|
||||||
|
timeout = parsed;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// Use default.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ModelCatalogRequest(family.getIdentifier(), baseUrl, apiKey, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable display name for the given provider family.
|
||||||
|
*
|
||||||
|
* @param family the provider family; must not be {@code null}
|
||||||
|
* @return the display name; never {@code null}
|
||||||
|
*/
|
||||||
|
private static String displayNameFor(AiProviderFamily family) {
|
||||||
|
return switch (family) {
|
||||||
|
case CLAUDE -> "Claude";
|
||||||
|
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an unmodifiable snapshot of the pending messages collected so far.
|
||||||
|
* <p>
|
||||||
|
* This method is intended for tests that need to inspect the message list after
|
||||||
|
* a retrieval completes.
|
||||||
|
*
|
||||||
|
* @return unmodifiable list of pending messages; never {@code null}
|
||||||
|
*/
|
||||||
|
public List<GuiMessageEntry> pendingMessagesSnapshot() {
|
||||||
|
return List.copyOf(pendingMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Prompt-Editor-Tab und dem zugehörigen
|
||||||
|
* Use-Case in der Application-Schicht.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die vom Use-Case bereitgestellte
|
||||||
|
* Funktionalität in den GUI-Adapter einschleust, ohne dass der GUI-Adapter direkt auf
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PromptPort} oder das Dateisystem
|
||||||
|
* zugreift.
|
||||||
|
* <p>
|
||||||
|
* <strong>Verantwortung:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>Prompt-Inhalt für die Anzeige im Editor laden.</li>
|
||||||
|
* <li>Bearbeiteten Inhalt atomar speichern.</li>
|
||||||
|
* <li>Standard-Prompt-Datei anlegen, wenn noch keine vorhanden ist.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Alle Implementierungen dieses Interfaces liegen in {@code pdf-umbenenner-bootstrap}.
|
||||||
|
* Das GUI-Modul kennt ausschließlich den Interface-Typ.
|
||||||
|
*/
|
||||||
|
public interface GuiPromptEditorPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den aktuellen Prompt-Inhalt aus der konfigurierten Quelle.
|
||||||
|
* <p>
|
||||||
|
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||||
|
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||||
|
*
|
||||||
|
* @return {@link PromptLoadingResult} mit Inhalt und Identifikator bei Erfolg,
|
||||||
|
* oder einem klassifizierten Fehler; nie {@code null}
|
||||||
|
*/
|
||||||
|
PromptLoadingResult loadCurrentPrompt();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert den übergebenen Inhalt atomar in die konfigurierte Prompt-Datei.
|
||||||
|
* <p>
|
||||||
|
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||||
|
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||||
|
*
|
||||||
|
* @param content der zu speichernde Inhalt; darf nicht {@code null} sein
|
||||||
|
* @return {@link PromptSaveResult} mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code content} null ist
|
||||||
|
*/
|
||||||
|
PromptSaveResult save(String content);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine Standard-Prompt-Datei an, falls noch keine vorhanden ist.
|
||||||
|
* <p>
|
||||||
|
* Muss auf einem Worker-Thread aufgerufen werden; das Ergebnis wird via
|
||||||
|
* {@code Platform.runLater} in den JavaFX Application Thread übergeben.
|
||||||
|
*
|
||||||
|
* @param suggestion Korrekturvorschlag mit dem Zielpfad; darf nicht {@code null} sein
|
||||||
|
* @return {@link CorrectionOutcome} mit dem Ergebnis der Aktion; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code suggestion} null ist
|
||||||
|
*/
|
||||||
|
CorrectionOutcome createDefaultPromptIfMissing(CorrectionSuggestion.CreatePromptFile suggestion);
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort} erzeugt.
|
||||||
|
* <p>
|
||||||
|
* Wird vom {@link GuiConfigurationEditorWorkspace} genutzt, um nach einem Konfigurations-Laden
|
||||||
|
* oder -Speichern einen neuen Port für den {@link GuiPromptEditorTab} zu erstellen, ohne dass
|
||||||
|
* der GUI-Adapter direkt von Bootstrap-internen Klassen abhängen muss.
|
||||||
|
* <p>
|
||||||
|
* Alle Implementierungen liegen in {@code pdf-umbenenner-bootstrap}.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiPromptEditorPortFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen {@link GuiPromptEditorPort} für den angegebenen Prompt-Dateipfad.
|
||||||
|
*
|
||||||
|
* @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
|
||||||
|
* @return vollständig verdrahteter Port; nie {@code null}
|
||||||
|
*/
|
||||||
|
GuiPromptEditorPort create(String promptFilePath);
|
||||||
|
}
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
import javafx.scene.control.TextArea;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.text.Font;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab „Prompt" im Hauptfenster des GUI-Adapters.
|
||||||
|
* <p>
|
||||||
|
* Ermöglicht das Lesen, Bearbeiten und Speichern der konfigurierten KI-Prompt-Datei
|
||||||
|
* direkt aus der Oberfläche heraus, ohne einen externen Editor öffnen zu müssen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Verhalten:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>Beim Öffnen des Tabs wird der aktuelle Prompt-Inhalt auf einem Worker-Thread geladen.</li>
|
||||||
|
* <li>Bearbeitungen erzeugen einen Dirty-State; der Tab-Titel erhält einen Asterisk.</li>
|
||||||
|
* <li>„Speichern" schreibt den Inhalt atomar via {@link GuiPromptEditorPort}.</li>
|
||||||
|
* <li>„Auf Standard zurücksetzen" befüllt die TextArea mit dem Default-Template,
|
||||||
|
* ohne zu speichern.</li>
|
||||||
|
* <li>Bei fehlendem Prompt wird ein Hinweis und ein „Standard-Prompt erstellen"-Button
|
||||||
|
* angezeigt.</li>
|
||||||
|
* <li>Tab-Wechsel oder Schließen mit Dirty-State löst einen Bestätigungsdialog aus.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Alle blockierenden Operationen (Laden, Speichern,
|
||||||
|
* Prompt-Datei anlegen) laufen auf einem Worker-Thread. UI-Aktualisierungen erfolgen
|
||||||
|
* ausschließlich via {@code Platform.runLater}.
|
||||||
|
*/
|
||||||
|
public class GuiPromptEditorTab {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiPromptEditorTab.class);
|
||||||
|
|
||||||
|
private static final String TAB_TITLE = "Prompt";
|
||||||
|
private static final String TAB_TITLE_DIRTY = "Prompt *";
|
||||||
|
|
||||||
|
private GuiPromptEditorPort promptEditorPort;
|
||||||
|
/** Konfigurierter Prompt-Dateipfad – wird für CreatePromptFile-Vorschläge benötigt. */
|
||||||
|
private String configuredPromptPath;
|
||||||
|
/** Konfigurierte maximale Titellänge – für den Default-Prompt-Inhalt. */
|
||||||
|
private int maxTitleLength;
|
||||||
|
|
||||||
|
// Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
|
||||||
|
/** Erzeugt Worker-Threads für blockierende Operationen. */
|
||||||
|
Function<Runnable, Thread> threadFactory;
|
||||||
|
/** Übergibt UI-Updates an den JavaFX Application Thread. */
|
||||||
|
Consumer<Runnable> fxDispatcher;
|
||||||
|
|
||||||
|
private final Tab tab = new Tab(TAB_TITLE);
|
||||||
|
private final TextArea textArea = new TextArea();
|
||||||
|
private final Label statusLabel = new Label();
|
||||||
|
private final Button saveButton = new Button("Speichern");
|
||||||
|
private final Button resetButton = new Button("Auf Standard zurücksetzen");
|
||||||
|
private final Button createDefaultButton = new Button("Standard-Prompt erstellen");
|
||||||
|
|
||||||
|
/** Zeigt an, ob der aktuelle Inhalt der TextArea vom geladenen Stand abweicht. */
|
||||||
|
private boolean dirty = false;
|
||||||
|
/** Zuletzt aus der Datei geladener Inhalt (Baseline). */
|
||||||
|
private String loadedContent = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt den Prompt-Editor-Tab.
|
||||||
|
*
|
||||||
|
* @param promptEditorPort Bridge-Port zum Use-Case; darf nicht {@code null} sein
|
||||||
|
* @param configuredPromptPath konfigurierter Pfad zur Prompt-Datei (für CreatePromptFile);
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param maxTitleLength konfigurierte maximale Titellänge für den Default-Prompt
|
||||||
|
* @throws NullPointerException wenn {@code promptEditorPort} oder {@code configuredPromptPath} null ist
|
||||||
|
*/
|
||||||
|
public GuiPromptEditorTab(GuiPromptEditorPort promptEditorPort,
|
||||||
|
String configuredPromptPath,
|
||||||
|
int maxTitleLength) {
|
||||||
|
this.promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||||
|
this.configuredPromptPath = Objects.requireNonNull(configuredPromptPath, "configuredPromptPath must not be null");
|
||||||
|
this.maxTitleLength = maxTitleLength;
|
||||||
|
// Standard-Implementierungen für den Produktionsbetrieb
|
||||||
|
this.threadFactory = runnable -> {
|
||||||
|
Thread t = new Thread(runnable, "gui-prompt-editor");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
this.fxDispatcher = Platform::runLater;
|
||||||
|
buildTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert das JavaFX-Tab-Objekt, das dem TabPane hinzugefügt werden kann.
|
||||||
|
*
|
||||||
|
* @return das Tab; nie {@code null}
|
||||||
|
*/
|
||||||
|
public Tab tab() {
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob der Prompt-Editor ungespeicherte Änderungen enthält.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn Dirty-State aktiv ist
|
||||||
|
*/
|
||||||
|
public boolean hasDirtyContent() {
|
||||||
|
return dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert den Tab auf eine neue Konfiguration.
|
||||||
|
* <p>
|
||||||
|
* Setzt Port, Prompt-Dateipfad und maximale Titellänge auf die neuen Werte.
|
||||||
|
* Der bisherige Lade-Baseline wird verworfen und der Dirty-State zurückgesetzt.
|
||||||
|
* Ist der Tab zum Zeitpunkt des Aufrufs sichtbar, wird ein erneutes Laden sofort
|
||||||
|
* ausgelöst; andernfalls erfolgt das Laden beim nächsten Öffnen des Tabs.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param newPort neuer Port für Prompt-Operationen; darf nicht {@code null} sein
|
||||||
|
* @param newPromptPath neuer konfigurierter Prompt-Dateipfad; darf nicht {@code null} sein
|
||||||
|
* @param newMaxTitleLength neue konfigurierte maximale Titellänge
|
||||||
|
*/
|
||||||
|
public void notifyConfigurationChanged(GuiPromptEditorPort newPort,
|
||||||
|
String newPromptPath,
|
||||||
|
int newMaxTitleLength) {
|
||||||
|
this.promptEditorPort = Objects.requireNonNull(newPort, "newPort must not be null");
|
||||||
|
this.configuredPromptPath = Objects.requireNonNull(newPromptPath, "newPromptPath must not be null");
|
||||||
|
this.maxTitleLength = newMaxTitleLength;
|
||||||
|
this.loadedContent = null;
|
||||||
|
this.dirty = false;
|
||||||
|
this.tab.setText(TAB_TITLE);
|
||||||
|
this.saveButton.setDisable(true);
|
||||||
|
if (tab.isSelected()) {
|
||||||
|
loadPromptAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwirft alle ungespeicherten Änderungen und setzt den Tab in den Lade-Bereitschaftszustand.
|
||||||
|
* <p>
|
||||||
|
* Setzt Dirty-State und Tab-Titel zurück. Ist der Tab zum Zeitpunkt des Aufrufs sichtbar,
|
||||||
|
* wird der Prompt-Inhalt sofort neu geladen; andernfalls erfolgt das Laden beim nächsten
|
||||||
|
* Öffnen des Tabs (gesteuert durch den Tab-Selektions-Listener).
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void discardChanges() {
|
||||||
|
this.loadedContent = null;
|
||||||
|
this.dirty = false;
|
||||||
|
this.tab.setText(TAB_TITLE);
|
||||||
|
this.saveButton.setDisable(true);
|
||||||
|
if (tab.isSelected()) {
|
||||||
|
loadPromptAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt einen Bestätigungsdialog, wenn ungespeicherte Änderungen vorhanden sind.
|
||||||
|
* Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen.
|
||||||
|
*
|
||||||
|
* @return {@code true} zum Verwerfen, {@code false} zum Abbrechen
|
||||||
|
*/
|
||||||
|
public boolean confirmDiscardIfDirty() {
|
||||||
|
if (!dirty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
|
||||||
|
alert.setTitle("Ungespeicherte Änderungen");
|
||||||
|
alert.setHeaderText("Der Prompt-Editor enthält ungespeicherte Änderungen.");
|
||||||
|
alert.setContentText("Möchten Sie die Änderungen verwerfen?");
|
||||||
|
alert.getButtonTypes().setAll(
|
||||||
|
new ButtonType("Verwerfen"),
|
||||||
|
ButtonType.CANCEL);
|
||||||
|
Optional<ButtonType> result = alert.showAndWait();
|
||||||
|
return result.isPresent() && result.get().getText().equals("Verwerfen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den aktuellen Prompt-Inhalt auf einem Worker-Thread und zeigt ihn in der TextArea an.
|
||||||
|
* <p>
|
||||||
|
* Muss vom JavaFX Application Thread aufgerufen werden. Die eigentliche I/O-Operation
|
||||||
|
* läuft auf einem Hintergrund-Thread; UI-Updates erfolgen via {@code fxDispatcher}.
|
||||||
|
*/
|
||||||
|
public void loadPromptAsync() {
|
||||||
|
setStatus("Lade Prompt-Datei ...");
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
createDefaultButton.setVisible(false);
|
||||||
|
createDefaultButton.setManaged(false);
|
||||||
|
|
||||||
|
Thread worker = threadFactory.apply(() -> {
|
||||||
|
var result = promptEditorPort.loadCurrentPrompt();
|
||||||
|
fxDispatcher.accept(() -> applyLoadResult(result));
|
||||||
|
});
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Private Aufbau
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void buildTab() {
|
||||||
|
tab.setClosable(false);
|
||||||
|
|
||||||
|
// TextArea – monospace Font für bessere Lesbarkeit
|
||||||
|
textArea.setWrapText(true);
|
||||||
|
textArea.setFont(Font.font("Monospace", 13));
|
||||||
|
textArea.setPrefRowCount(20);
|
||||||
|
textArea.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_TEXTAREA));
|
||||||
|
VBox.setVgrow(textArea, Priority.ALWAYS);
|
||||||
|
|
||||||
|
// Dirty-State-Tracking
|
||||||
|
textArea.textProperty().addListener((obs, oldVal, newVal) -> {
|
||||||
|
if (loadedContent != null) {
|
||||||
|
boolean nowDirty = !newVal.equals(loadedContent);
|
||||||
|
if (nowDirty != dirty) {
|
||||||
|
dirty = nowDirty;
|
||||||
|
tab.setText(dirty ? TAB_TITLE_DIRTY : TAB_TITLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status-Label
|
||||||
|
statusLabel.setWrapText(true);
|
||||||
|
statusLabel.setStyle("-fx-text-fill: #555555;");
|
||||||
|
|
||||||
|
// Buttons verdrahten
|
||||||
|
saveButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_SPEICHERN));
|
||||||
|
saveButton.setOnAction(e -> requestSave());
|
||||||
|
|
||||||
|
resetButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_ZURUECKSETZEN));
|
||||||
|
resetButton.setOnAction(e -> resetToDefault());
|
||||||
|
|
||||||
|
createDefaultButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_STANDARD_ANLEGEN));
|
||||||
|
createDefaultButton.setOnAction(e -> requestCreateDefault());
|
||||||
|
createDefaultButton.setVisible(false);
|
||||||
|
createDefaultButton.setManaged(false);
|
||||||
|
|
||||||
|
HBox buttonBar = new HBox(8, saveButton, resetButton, createDefaultButton);
|
||||||
|
buttonBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
buttonBar.setPadding(new Insets(6, 0, 0, 0));
|
||||||
|
|
||||||
|
VBox content = new VBox(6, textArea, statusLabel, buttonBar);
|
||||||
|
content.setPadding(new Insets(12));
|
||||||
|
VBox.setVgrow(textArea, Priority.ALWAYS);
|
||||||
|
|
||||||
|
BorderPane root = new BorderPane(content);
|
||||||
|
tab.setContent(root);
|
||||||
|
|
||||||
|
// Beim Öffnen des Tabs laden (falls Konfiguration bereits vorhanden)
|
||||||
|
tab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
|
||||||
|
if (Boolean.TRUE.equals(isSelected) && loadedContent == null) {
|
||||||
|
loadPromptAsync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyLoadResult(de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult result) {
|
||||||
|
if (result instanceof PromptLoadingSuccess success) {
|
||||||
|
loadedContent = success.promptContent();
|
||||||
|
textArea.setText(loadedContent);
|
||||||
|
textArea.setEditable(true);
|
||||||
|
saveButton.setDisable(false);
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
createDefaultButton.setVisible(false);
|
||||||
|
createDefaultButton.setManaged(false);
|
||||||
|
setStatus("Prompt-Datei geladen. Identifikator: " + success.promptIdentifier().identifier());
|
||||||
|
dirty = false;
|
||||||
|
tab.setText(TAB_TITLE);
|
||||||
|
LOG.info("Prompt-Editor: Prompt-Datei erfolgreich geladen (Identifikator: {}).",
|
||||||
|
success.promptIdentifier().identifier());
|
||||||
|
} else if (result instanceof PromptLoadingFailure failure) {
|
||||||
|
boolean fileNotFound = "FILE_NOT_FOUND".equals(failure.failureReason());
|
||||||
|
if (fileNotFound) {
|
||||||
|
// Datei fehlt – Hinweis und Anlegen-Button anzeigen
|
||||||
|
loadedContent = null;
|
||||||
|
textArea.setEditable(false);
|
||||||
|
textArea.clear();
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
createDefaultButton.setVisible(true);
|
||||||
|
createDefaultButton.setManaged(true);
|
||||||
|
setStatus("Keine Prompt-Datei vorhanden. Legen Sie eine Standard-Datei an oder "
|
||||||
|
+ "konfigurieren Sie den Pfad im Konfigurationstab.");
|
||||||
|
LOG.info("Prompt-Editor: Keine Prompt-Datei am konfigurierten Pfad vorhanden.");
|
||||||
|
} else {
|
||||||
|
// Anderer Fehler (I/O, leer usw.)
|
||||||
|
loadedContent = null;
|
||||||
|
textArea.setEditable(false);
|
||||||
|
textArea.clear();
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
createDefaultButton.setVisible(false);
|
||||||
|
createDefaultButton.setManaged(false);
|
||||||
|
setStatus("Fehler beim Laden der Prompt-Datei: " + failure.failureMessage());
|
||||||
|
LOG.warn("Prompt-Editor: Laden fehlgeschlagen ({}): {}",
|
||||||
|
failure.failureReason(), failure.failureMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestSave() {
|
||||||
|
String currentText = textArea.getText();
|
||||||
|
|
||||||
|
// Leerer Prompt: Bestätigungsdialog
|
||||||
|
if (currentText.trim().isEmpty()) {
|
||||||
|
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
||||||
|
confirm.setTitle("Leerer Prompt");
|
||||||
|
confirm.setHeaderText("Der Prompt ist leer.");
|
||||||
|
confirm.setContentText("Wirklich eine leere Prompt-Datei speichern?");
|
||||||
|
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
Optional<ButtonType> choice = confirm.showAndWait();
|
||||||
|
if (choice.isEmpty() || choice.get() != ButtonType.OK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Speichere ...");
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
|
||||||
|
Thread worker = threadFactory.apply(() -> {
|
||||||
|
PromptSaveResult result = promptEditorPort.save(currentText);
|
||||||
|
fxDispatcher.accept(() -> applySaveResult(result, currentText));
|
||||||
|
});
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applySaveResult(PromptSaveResult result, String savedContent) {
|
||||||
|
saveButton.setDisable(false);
|
||||||
|
if (result instanceof PromptSaveResult.Saved saved) {
|
||||||
|
loadedContent = savedContent;
|
||||||
|
dirty = false;
|
||||||
|
tab.setText(TAB_TITLE);
|
||||||
|
setStatus("Prompt-Datei gespeichert: " + saved.absolutePath());
|
||||||
|
textArea.setEditable(true);
|
||||||
|
LOG.info("Prompt-Editor: Prompt-Datei gespeichert unter {}.", saved.absolutePath());
|
||||||
|
} else if (result instanceof PromptSaveResult.TargetDirectoryMissing missing) {
|
||||||
|
setStatus("Fehler: " + missing.message());
|
||||||
|
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Ordner fehlt: {}", missing.message());
|
||||||
|
} else if (result instanceof PromptSaveResult.WriteFailed failed) {
|
||||||
|
setStatus("Fehler beim Schreiben: " + failed.message());
|
||||||
|
LOG.warn("Prompt-Editor: Speichern fehlgeschlagen – Schreibfehler: {}", failed.message());
|
||||||
|
} else if (result instanceof PromptSaveResult.AtomicMoveFailed atomicFailed) {
|
||||||
|
setStatus("Fehler: Atomares Speichern fehlgeschlagen (kein Fallback). " + atomicFailed.message());
|
||||||
|
LOG.warn("Prompt-Editor: Atomares Verschieben fehlgeschlagen: {}", atomicFailed.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetToDefault() {
|
||||||
|
String defaultContent = de.gecheckt.pdf.umbenenner.application.validation
|
||||||
|
.technicaltest.DefaultPromptTemplate.defaultContent(maxTitleLength);
|
||||||
|
textArea.setText(defaultContent);
|
||||||
|
textArea.setEditable(true);
|
||||||
|
saveButton.setDisable(false);
|
||||||
|
setStatus("Standard-Prompt-Inhalt in den Editor geladen (noch nicht gespeichert).");
|
||||||
|
LOG.info("Prompt-Editor: Standard-Prompt-Inhalt in TextArea geladen (nicht gespeichert).");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestCreateDefault() {
|
||||||
|
createDefaultButton.setDisable(true);
|
||||||
|
setStatus("Lege Standard-Prompt-Datei an ...");
|
||||||
|
|
||||||
|
CorrectionSuggestion.CreatePromptFile suggestion = new CorrectionSuggestion.CreatePromptFile(
|
||||||
|
configuredPromptPath,
|
||||||
|
"Standard-Prompt-Datei anlegen",
|
||||||
|
maxTitleLength);
|
||||||
|
|
||||||
|
Thread worker = threadFactory.apply(() -> {
|
||||||
|
CorrectionOutcome outcome = promptEditorPort.createDefaultPromptIfMissing(suggestion);
|
||||||
|
fxDispatcher.accept(() -> applyCreateDefaultResult(outcome));
|
||||||
|
});
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyCreateDefaultResult(CorrectionOutcome outcome) {
|
||||||
|
createDefaultButton.setDisable(false);
|
||||||
|
if (outcome instanceof CorrectionOutcome.Applied applied) {
|
||||||
|
setStatus(applied.message() + " Lade Inhalt ...");
|
||||||
|
LOG.info("Prompt-Editor: Standard-Prompt-Datei angelegt. Lade neu.");
|
||||||
|
// Inhalt sofort neu laden
|
||||||
|
loadPromptAsync();
|
||||||
|
} else if (outcome instanceof CorrectionOutcome.Failed failed) {
|
||||||
|
setStatus("Fehler beim Anlegen der Standard-Prompt-Datei: " + failed.errorMessage());
|
||||||
|
LOG.warn("Prompt-Editor: Anlegen der Standard-Prompt-Datei fehlgeschlagen: {}", failed.errorMessage());
|
||||||
|
} else if (outcome instanceof CorrectionOutcome.NotAttempted notAttempted) {
|
||||||
|
setStatus("Aktion nicht verfügbar: " + notAttempted.reason());
|
||||||
|
LOG.warn("Prompt-Editor: Anlegen nicht versucht: {}", notAttempted.reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setStatus(String message) {
|
||||||
|
statusLabel.setText(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+474
@@ -0,0 +1,474 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerSessionTotals;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStartException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Separator;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fünfter Haupt-Tab des JavaFX-Editorfensters: die Scheduler-Steuerungsansicht.
|
||||||
|
* <p>
|
||||||
|
* Zeigt den aktuellen Zustand des automatischen Schedulers und erlaubt dessen
|
||||||
|
* Steuerung über {@link SchedulerControlUseCase}. Der Tab-Inhalt wird im Sekundentakt
|
||||||
|
* durch {@link #updateStatus(SchedulerStatus)} aktualisiert, das von der zentralen
|
||||||
|
* {@link GuiStatusRefreshTimeline} aufgerufen wird.
|
||||||
|
*
|
||||||
|
* <h2>Bereiche</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>Scheduler-Steuerung</strong>: Status-Anzeige (● Aktiv / ○ Gestoppt),
|
||||||
|
* Start-/Stopp-Schaltflächen, Countdown bis zum nächsten Lauf,
|
||||||
|
* Letzter-Lauf-Info, Fehlermeldung und Intervall-Konfiguration.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
|
||||||
|
* werden. Start-, Stopp- und Speichern-Aktionen werden auf einem dedizierten
|
||||||
|
* Hintergrund-Worker-Thread ({@code gui-scheduler-control}) ausgeführt.
|
||||||
|
*/
|
||||||
|
public final class GuiSchedulerTab {
|
||||||
|
private static final String HEADER_LABEL_STYLE = "-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #7f8c8d;";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiSchedulerTab.class);
|
||||||
|
|
||||||
|
private static final String TAB_TITLE = "Scheduler";
|
||||||
|
|
||||||
|
/** Mindestwert für das konfigurierbare Ausführungsintervall. */
|
||||||
|
static final int MIN_INTERVAL_SECONDS = 30;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIME_FORMATTER =
|
||||||
|
DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault());
|
||||||
|
|
||||||
|
private final Tab tab = new Tab(TAB_TITLE);
|
||||||
|
// Not final: may be updated via onSchedulerAvailable after the tab was created without a use
|
||||||
|
// case (e.g., when auto-load initialises the scheduler after the workspace was already built).
|
||||||
|
// Declared volatile so worker-thread reads (executeStart/Stop) see the write from the FX thread.
|
||||||
|
private volatile Optional<SchedulerControlUseCase> schedulerUseCase;
|
||||||
|
private final Supplier<Boolean> isConfigDirty;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Bereich 1: Scheduler-Steuerung
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private final Label statusLabel = new Label("○ Gestoppt");
|
||||||
|
private final Button startButton = new Button("Scheduler starten");
|
||||||
|
private final Button stopButton = new Button("Scheduler stoppen");
|
||||||
|
private final Label nextTickLabel = new Label();
|
||||||
|
private final Label lastRunLabel = new Label("Noch kein Lauf in dieser Sitzung.");
|
||||||
|
private final Label sessionTotalsLabel = new Label();
|
||||||
|
private final Label lastErrorLabel = new Label();
|
||||||
|
private final TextField intervalField = new TextField();
|
||||||
|
private final Label intervalValidationLabel = new Label();
|
||||||
|
|
||||||
|
private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "gui-scheduler-control");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Scheduler-Tab.
|
||||||
|
*
|
||||||
|
* @param schedulerUseCase optionaler Use Case zur Scheduler-Steuerung;
|
||||||
|
* {@code null} wird als leer behandelt
|
||||||
|
* @param isConfigDirty Supplier der {@code true} zurückgibt wenn der
|
||||||
|
* Konfigurationseditor ungespeicherte Änderungen hat;
|
||||||
|
* {@code null} wird als immer {@code false} behandelt
|
||||||
|
*/
|
||||||
|
public GuiSchedulerTab(
|
||||||
|
Optional<SchedulerControlUseCase> schedulerUseCase,
|
||||||
|
Supplier<Boolean> isConfigDirty) {
|
||||||
|
this.schedulerUseCase = Objects.requireNonNullElse(schedulerUseCase, Optional.empty());
|
||||||
|
this.isConfigDirty = isConfigDirty != null ? isConfigDirty : () -> false;
|
||||||
|
tab.setClosable(false);
|
||||||
|
buildUi();
|
||||||
|
applyInitialState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den JavaFX-Tab-Knoten für den Einhang in das {@code TabPane}.
|
||||||
|
*
|
||||||
|
* @return Tab-Knoten; nie {@code null}
|
||||||
|
*/
|
||||||
|
public Tab tab() {
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht den Scheduler-Use-Case für diesen Tab verfügbar, nachdem er nach einem
|
||||||
|
* erfolgreichen Datei-Öffnen initialisiert wurde.
|
||||||
|
* <p>
|
||||||
|
* Wird vom Workspace auf dem JavaFX Application Thread aufgerufen, nachdem der
|
||||||
|
* {@link GuiApplicationContextInitializer} auf einem Hintergrund-Thread einen
|
||||||
|
* {@link SchedulerControlUseCase} geliefert hat. Hat keine Wirkung, wenn bereits
|
||||||
|
* ein Use Case vorhanden ist.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param useCase der neu initialisierte Use Case; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public void onSchedulerAvailable(SchedulerControlUseCase useCase) {
|
||||||
|
if (schedulerUseCase.isPresent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedulerUseCase = Optional.of(useCase);
|
||||||
|
intervalField.setText(String.valueOf(useCase.getIntervalSeconds()));
|
||||||
|
intervalField.setEditable(true);
|
||||||
|
intervalField.setDisable(false);
|
||||||
|
startButton.setDisable(false);
|
||||||
|
startButton.setTooltip(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuell verdrahteten Scheduler-Use-Case zurück.
|
||||||
|
* <p>
|
||||||
|
* Wird von der zentralen Status-Refresh-Timeline benötigt, weil der Use Case
|
||||||
|
* erst nach erfolgreichem Datei-Öffnen verfügbar wird (z. B. durch Auto-Load
|
||||||
|
* der zuletzt geladenen Konfiguration) und damit nicht im
|
||||||
|
* unveränderlichen {@code GuiStartupContext} steht.
|
||||||
|
*
|
||||||
|
* @return aktueller Use Case oder {@code Optional.empty()} wenn keiner verdrahtet ist
|
||||||
|
*/
|
||||||
|
public Optional<SchedulerControlUseCase> currentSchedulerUseCase() {
|
||||||
|
return schedulerUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert alle Tab-Elemente anhand des aktuellen Scheduler-Status.
|
||||||
|
* <p>
|
||||||
|
* Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem
|
||||||
|
* JavaFX Application Thread aufgerufen. Implementiert alle in der Spezifikation
|
||||||
|
* definierten Button-Zustände, Label-Texte und Sichtbarkeitsregeln.
|
||||||
|
*
|
||||||
|
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public void updateStatus(SchedulerStatus status) {
|
||||||
|
updateStatusLabel(status);
|
||||||
|
updateButtons(status);
|
||||||
|
updateNextTickLabel(status);
|
||||||
|
updateLastRunLabel(status);
|
||||||
|
updateSessionTotalsLabel(status);
|
||||||
|
updateLastErrorLabel(status);
|
||||||
|
updateIntervalFieldEditability(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// UI-Aufbau
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void buildUi() {
|
||||||
|
VBox controlArea = buildControlArea();
|
||||||
|
tab.setContent(controlArea);
|
||||||
|
wireActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private VBox buildControlArea() {
|
||||||
|
statusLabel.setStyle(HEADER_LABEL_STYLE);
|
||||||
|
|
||||||
|
stopButton.setDisable(true);
|
||||||
|
HBox buttonBox = new HBox(10, startButton, stopButton);
|
||||||
|
|
||||||
|
nextTickLabel.setVisible(false);
|
||||||
|
nextTickLabel.setManaged(false);
|
||||||
|
|
||||||
|
lastRunLabel.setWrapText(true);
|
||||||
|
|
||||||
|
sessionTotalsLabel.setWrapText(true);
|
||||||
|
sessionTotalsLabel.setStyle("-fx-text-fill: #7f8c8d;");
|
||||||
|
sessionTotalsLabel.setVisible(false);
|
||||||
|
sessionTotalsLabel.setManaged(false);
|
||||||
|
|
||||||
|
lastErrorLabel.setStyle("-fx-text-fill: #c0392b;");
|
||||||
|
lastErrorLabel.setWrapText(true);
|
||||||
|
lastErrorLabel.setVisible(false);
|
||||||
|
lastErrorLabel.setManaged(false);
|
||||||
|
|
||||||
|
Label intervalLabel = new Label("Intervall (Sekunden):");
|
||||||
|
intervalField.setPrefColumnCount(10);
|
||||||
|
HBox intervalBox = new HBox(10, intervalLabel, intervalField);
|
||||||
|
intervalBox.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
|
||||||
|
intervalValidationLabel.setStyle("-fx-text-fill: #c0392b; -fx-font-size: 11px;");
|
||||||
|
intervalValidationLabel.setWrapText(true);
|
||||||
|
intervalValidationLabel.setVisible(false);
|
||||||
|
intervalValidationLabel.setManaged(false);
|
||||||
|
|
||||||
|
VBox controlArea = new VBox(12,
|
||||||
|
statusLabel,
|
||||||
|
buttonBox,
|
||||||
|
nextTickLabel,
|
||||||
|
lastRunLabel,
|
||||||
|
sessionTotalsLabel,
|
||||||
|
lastErrorLabel,
|
||||||
|
new Separator(),
|
||||||
|
intervalBox,
|
||||||
|
intervalValidationLabel);
|
||||||
|
controlArea.setPadding(new Insets(16));
|
||||||
|
return controlArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void wireActions() {
|
||||||
|
startButton.setOnAction(e -> executeStart());
|
||||||
|
stopButton.setOnAction(e -> executeStop());
|
||||||
|
|
||||||
|
intervalField.focusedProperty().addListener((obs, wasFocused, focused) -> {
|
||||||
|
if (!focused) {
|
||||||
|
validateAndSaveInterval();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyInitialState() {
|
||||||
|
if (schedulerUseCase.isEmpty()) {
|
||||||
|
startButton.setDisable(true);
|
||||||
|
startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
|
||||||
|
stopButton.setDisable(true);
|
||||||
|
intervalField.setEditable(false);
|
||||||
|
intervalField.setDisable(true);
|
||||||
|
} else {
|
||||||
|
intervalField.setText(String.valueOf(schedulerUseCase.get().getIntervalSeconds()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// updateStatus-Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void updateStatusLabel(SchedulerStatus status) {
|
||||||
|
switch (status.state()) {
|
||||||
|
case STOPPED -> {
|
||||||
|
statusLabel.setText("○ Gestoppt");
|
||||||
|
statusLabel.setStyle(HEADER_LABEL_STYLE);
|
||||||
|
}
|
||||||
|
case STARTING -> {
|
||||||
|
statusLabel.setText("⟳ Wird gestartet…");
|
||||||
|
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #e67e22;");
|
||||||
|
}
|
||||||
|
case RUNNING_IDLE -> {
|
||||||
|
statusLabel.setText("● Aktiv");
|
||||||
|
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
|
||||||
|
}
|
||||||
|
case RUNNING_BATCH_ACTIVE -> {
|
||||||
|
statusLabel.setText("● Aktiv – Lauf aktiv");
|
||||||
|
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
|
||||||
|
}
|
||||||
|
case STOPPING_BATCH_ACTIVE -> {
|
||||||
|
statusLabel.setText("○ Gestoppt – aktueller Lauf läuft noch");
|
||||||
|
statusLabel.setStyle(HEADER_LABEL_STYLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateButtons(SchedulerStatus status) {
|
||||||
|
boolean noUseCase = schedulerUseCase.isEmpty();
|
||||||
|
boolean configDirty = Boolean.TRUE.equals(isConfigDirty.get());
|
||||||
|
|
||||||
|
switch (status.state()) {
|
||||||
|
case STOPPED -> {
|
||||||
|
stopButton.setDisable(true);
|
||||||
|
if (noUseCase) {
|
||||||
|
startButton.setDisable(true);
|
||||||
|
startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
|
||||||
|
} else if (configDirty) {
|
||||||
|
startButton.setDisable(true);
|
||||||
|
startButton.setTooltip(new Tooltip("Bitte Konfiguration speichern"));
|
||||||
|
} else {
|
||||||
|
startButton.setDisable(false);
|
||||||
|
startButton.setTooltip(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case STARTING -> {
|
||||||
|
startButton.setDisable(true);
|
||||||
|
stopButton.setDisable(true);
|
||||||
|
}
|
||||||
|
case RUNNING_IDLE, RUNNING_BATCH_ACTIVE -> {
|
||||||
|
startButton.setDisable(true);
|
||||||
|
startButton.setTooltip(null);
|
||||||
|
stopButton.setDisable(false);
|
||||||
|
}
|
||||||
|
case STOPPING_BATCH_ACTIVE -> {
|
||||||
|
startButton.setDisable(true);
|
||||||
|
stopButton.setDisable(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateNextTickLabel(SchedulerStatus status) {
|
||||||
|
if (status.state() == SchedulerState.RUNNING_IDLE && status.nextTickAt().isPresent()) {
|
||||||
|
long remaining = ChronoUnit.SECONDS.between(Instant.now(), status.nextTickAt().get());
|
||||||
|
if (remaining > 0) {
|
||||||
|
long minutes = remaining / 60;
|
||||||
|
long seconds = remaining % 60;
|
||||||
|
nextTickLabel.setText(String.format("Nächster Lauf in: %02d:%02d", minutes, seconds));
|
||||||
|
} else {
|
||||||
|
nextTickLabel.setText("Lauf steht bevor…");
|
||||||
|
}
|
||||||
|
nextTickLabel.setVisible(true);
|
||||||
|
nextTickLabel.setManaged(true);
|
||||||
|
} else {
|
||||||
|
nextTickLabel.setVisible(false);
|
||||||
|
nextTickLabel.setManaged(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateLastRunLabel(SchedulerStatus status) {
|
||||||
|
if (status.lastRunEndedAt().isPresent() && status.lastRunSummary().isPresent()) {
|
||||||
|
Instant endedAt = status.lastRunEndedAt().get();
|
||||||
|
RunSummary summary = status.lastRunSummary().get();
|
||||||
|
String timeStr = TIME_FORMATTER.format(endedAt);
|
||||||
|
boolean noDocuments = summary.successCount() == 0
|
||||||
|
&& summary.failedCount() == 0;
|
||||||
|
if (noDocuments) {
|
||||||
|
lastRunLabel.setText("Letzter Lauf: " + timeStr + " – keine neuen Dokumente");
|
||||||
|
} else {
|
||||||
|
lastRunLabel.setText("Letzter Lauf: " + timeStr + " – "
|
||||||
|
+ summary.successCount() + " verarbeitet, "
|
||||||
|
+ summary.failedCount() + " Fehler");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastRunLabel.setText("Noch kein Lauf in dieser Sitzung.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateSessionTotalsLabel(SchedulerStatus status) {
|
||||||
|
Optional<SchedulerSessionTotals> totals = status.sessionTotals();
|
||||||
|
if (totals.isPresent()) {
|
||||||
|
SchedulerSessionTotals t = totals.get();
|
||||||
|
sessionTotalsLabel.setText("Seit Scheduler-Start: "
|
||||||
|
+ t.successCount() + " verarbeitet, "
|
||||||
|
+ t.failedCount() + " Fehler");
|
||||||
|
sessionTotalsLabel.setVisible(true);
|
||||||
|
sessionTotalsLabel.setManaged(true);
|
||||||
|
} else {
|
||||||
|
sessionTotalsLabel.setVisible(false);
|
||||||
|
sessionTotalsLabel.setManaged(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateLastErrorLabel(SchedulerStatus status) {
|
||||||
|
Optional<String> lastError = status.lastError();
|
||||||
|
if (lastError.isPresent() && !lastError.get().isBlank()) {
|
||||||
|
lastErrorLabel.setText("Fehler: " + lastError.get());
|
||||||
|
lastErrorLabel.setVisible(true);
|
||||||
|
lastErrorLabel.setManaged(true);
|
||||||
|
} else {
|
||||||
|
lastErrorLabel.setVisible(false);
|
||||||
|
lastErrorLabel.setManaged(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateIntervalFieldEditability(SchedulerStatus status) {
|
||||||
|
boolean editable = status.state() == SchedulerState.STOPPED
|
||||||
|
&& schedulerUseCase.isPresent()
|
||||||
|
&& !Boolean.TRUE.equals(isConfigDirty.get());
|
||||||
|
intervalField.setEditable(editable);
|
||||||
|
intervalField.setDisable(!editable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Aktions-Handler
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void executeStart() {
|
||||||
|
LOG.info("GUI: Scheduler-Start angefordert.");
|
||||||
|
startButton.setDisable(true);
|
||||||
|
stopButton.setDisable(true);
|
||||||
|
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
|
||||||
|
try {
|
||||||
|
uc.start();
|
||||||
|
LOG.info("GUI: Scheduler erfolgreich gestartet.");
|
||||||
|
} catch (SchedulerStartException e) {
|
||||||
|
LOG.warn("GUI: Scheduler-Start fehlgeschlagen: {}", e.getMessage());
|
||||||
|
Platform.runLater(() -> showStartErrorAlert(e.getMessage()));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI: Unerwarteter Fehler beim Starten des Schedulers.", e);
|
||||||
|
Platform.runLater(() -> showStartErrorAlert("Unerwarteter Fehler: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeStop() {
|
||||||
|
LOG.info("GUI: Scheduler-Stopp angefordert.");
|
||||||
|
startButton.setDisable(true);
|
||||||
|
stopButton.setDisable(true);
|
||||||
|
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
|
||||||
|
try {
|
||||||
|
uc.stop();
|
||||||
|
LOG.info("GUI: Scheduler gestoppt.");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI: Unerwarteter Fehler beim Stoppen des Schedulers.", e);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateAndSaveInterval() {
|
||||||
|
String text = intervalField.getText() == null ? "" : intervalField.getText().trim();
|
||||||
|
try {
|
||||||
|
int value = Integer.parseInt(text);
|
||||||
|
if (value < MIN_INTERVAL_SECONDS) {
|
||||||
|
showIntervalValidationError(
|
||||||
|
"Mindestintervall ist " + MIN_INTERVAL_SECONDS + " Sekunden.");
|
||||||
|
} else {
|
||||||
|
hideIntervalValidationError();
|
||||||
|
workerExecutor.submit(() -> schedulerUseCase.ifPresent(uc -> {
|
||||||
|
try {
|
||||||
|
uc.saveIntervalSeconds(value);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.warn("GUI: Fehler beim Speichern des Scheduler-Intervalls: {}", e.getMessage());
|
||||||
|
Platform.runLater(() -> showIntervalValidationError(
|
||||||
|
"Speichern fehlgeschlagen: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
showIntervalValidationError("Bitte eine ganze Zahl eingeben.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showIntervalValidationError(String message) {
|
||||||
|
intervalValidationLabel.setText(message);
|
||||||
|
intervalValidationLabel.setVisible(true);
|
||||||
|
intervalValidationLabel.setManaged(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void hideIntervalValidationError() {
|
||||||
|
intervalValidationLabel.setVisible(false);
|
||||||
|
intervalValidationLabel.setManaged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void showStartErrorAlert(String message) {
|
||||||
|
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||||
|
alert.setTitle("Scheduler-Start fehlgeschlagen");
|
||||||
|
alert.setHeaderText("Der Scheduler konnte nicht gestartet werden.");
|
||||||
|
alert.setContentText(message != null ? message : "Unbekannter Fehler.");
|
||||||
|
alert.showAndWait();
|
||||||
|
}
|
||||||
|
}
|
||||||
+618
-11
@@ -2,50 +2,657 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
|||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLaunchOutcome;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunLauncher;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiHistoricalDocumentContextPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileCopyPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiManualFileRenamePort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiMiniRunLauncher;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.PathCheckPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immutable startup data for the GUI adapter.
|
* Immutable startup data for the GUI adapter.
|
||||||
* <p>
|
* <p>
|
||||||
* Carries the initial editor state, the optional startup notice, the file-loading callback
|
* Carries the initial editor state, the optional startup notice, the file-loading callback,
|
||||||
* and the file-writing callback that the workspace uses for native save actions.
|
* the file-writing callback that the workspace uses for native save actions, the
|
||||||
|
* {@link AiModelCatalogPort} used to retrieve available AI model lists on demand, the
|
||||||
|
* {@link ApiKeyResolutionPort} used by the editor validation to determine the effective
|
||||||
|
* API key provenance from environment variables, the {@link ProviderTechnicalTestService}
|
||||||
|
* used to execute provider-specific technical checks, the {@link PathCheckPort}
|
||||||
|
* used to verify filesystem path accessibility for configuration values, the
|
||||||
|
* {@link TechnicalTestOrchestrator} used by the "Technische Tests ausführen" action, the
|
||||||
|
* {@link CorrectionExecutionService} used to execute corrective actions after a
|
||||||
|
* technical test run has been confirmed by the user, the {@link GuiBatchRunLauncher} used
|
||||||
|
* to execute regular batch runs, the {@link GuiMiniRunLauncher} used to execute targeted
|
||||||
|
* mini-runs for selected documents, the {@link GuiResetDocumentStatusPort} used to
|
||||||
|
* reset the persistence status of selected documents, and the
|
||||||
|
* {@link GuiManualFileRenamePort} used to manually rename a target file from the GUI,
|
||||||
|
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
|
||||||
|
* folder for documents that have not yet been successfully processed, and
|
||||||
|
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
|
||||||
|
* context for documents that were skipped in the current run, the resolved application
|
||||||
|
* version string that the status bar displays at the bottom of the main window, and the
|
||||||
|
* optional {@link SchedulerControlUseCase} for controlling the automatic scheduler.
|
||||||
|
* <p>
|
||||||
|
* The optional {@code applicationContextError} carries a human-readable German error
|
||||||
|
* message when the bootstrap-side application run context could not be initialised at
|
||||||
|
* startup (e.g., invalid or incomplete configuration). An empty value signals that the
|
||||||
|
* run context was built successfully and batch runs can be launched immediately.
|
||||||
|
* <p>
|
||||||
|
* The optional {@code schedulerControlUseCase} is present when the automatic scheduler
|
||||||
|
* was successfully wired at startup. An empty value means scheduler control is not
|
||||||
|
* available in this startup context (e.g., no valid configuration was loaded at startup).
|
||||||
|
* <p>
|
||||||
|
* The optional {@code configurationFileLockPort} is present when the GUI can acquire an
|
||||||
|
* OS-level exclusive lock on the configuration file before a manual batch run. When present,
|
||||||
|
* it is acquired by the {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}
|
||||||
|
* on the worker thread before each run and released in a finally block. An empty value means
|
||||||
|
* no locking is performed (e.g., no valid configuration was loaded at startup, or locking is
|
||||||
|
* not required in this context).
|
||||||
|
* <p>
|
||||||
|
* The {@code applicationContextInitializer} is invoked on a background thread each time the
|
||||||
|
* workspace loads a configuration file (auto-load at startup and manual open). Bootstrap
|
||||||
|
* provides an implementation that builds the application run context and wires the scheduler.
|
||||||
|
* <p>
|
||||||
|
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
|
||||||
|
* know about provider-specific HTTP details or adapter wiring.
|
||||||
*/
|
*/
|
||||||
public record GuiStartupContext(
|
public record GuiStartupContext(
|
||||||
GuiConfigurationEditorState initialState,
|
GuiConfigurationEditorState initialState,
|
||||||
Optional<String> startupNotice,
|
Optional<String> startupNotice,
|
||||||
GuiConfigurationFileLoader configurationFileLoader,
|
GuiConfigurationFileLoader configurationFileLoader,
|
||||||
GuiConfigurationFileWriter configurationFileWriter) {
|
GuiConfigurationFileWriter configurationFileWriter,
|
||||||
|
AiModelCatalogPort modelCatalogPort,
|
||||||
|
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||||
|
ProviderTechnicalTestService providerTechnicalTestService,
|
||||||
|
PathCheckPort pathCheckPort,
|
||||||
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
|
CorrectionExecutionService correctionExecutionService,
|
||||||
|
GuiBatchRunLauncher batchRunLauncher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
||||||
|
GuiManualFileRenamePort manualFileRenamePort,
|
||||||
|
GuiManualFileCopyPort manualFileCopyPort,
|
||||||
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||||
|
String applicationVersion,
|
||||||
|
GuiPromptEditorPort promptEditorPort,
|
||||||
|
GuiHistoryOverviewPort historyOverviewPort,
|
||||||
|
GuiHistoryDetailsPort historyDetailsPort,
|
||||||
|
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
||||||
|
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
|
||||||
|
GuiPromptEditorPortFactory promptEditorPortFactory,
|
||||||
|
GuiCreateNewDatabasePort createNewDatabasePort,
|
||||||
|
Optional<String> applicationContextError,
|
||||||
|
Optional<SchedulerControlUseCase> schedulerControlUseCase,
|
||||||
|
Optional<ConfigurationFileLockPort> configurationFileLockPort,
|
||||||
|
GuiApplicationContextInitializer applicationContextInitializer,
|
||||||
|
Optional<GuiModelPriceManagementPort> modelPriceManagementPort) {
|
||||||
|
private static final String NO_PROMPT_PORT_MSG = "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.";
|
||||||
|
private static final String NO_PORT_MSG = "Kein Port in diesem Startkontext.";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a startup context.
|
* Creates a fully wired startup context.
|
||||||
*
|
*
|
||||||
* @param initialState initial editor state; must not be {@code null}
|
* @param initialState initial editor state; must not be {@code null}
|
||||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||||
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
||||||
|
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||||
|
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||||
|
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
|
||||||
|
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||||
|
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||||
|
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||||
|
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
|
||||||
|
* @param miniRunLauncher bridge that executes a targeted mini-run for selected
|
||||||
|
* documents; must not be {@code null}
|
||||||
|
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
|
||||||
|
* documents; must not be {@code null}
|
||||||
|
* @param manualFileRenamePort bridge that renames a target file manually from the GUI;
|
||||||
|
* must not be {@code null}
|
||||||
|
* @param manualFileCopyPort bridge that copies a source file to the target folder for
|
||||||
|
* documents that have not yet been successfully processed;
|
||||||
|
* must not be {@code null}
|
||||||
|
* @param historicalDocumentContextPort bridge that resolves the historical processing context
|
||||||
|
* for skipped documents; must not be {@code null}
|
||||||
|
* @param applicationVersion resolved application version string shown in the status
|
||||||
|
* bar; {@code null} defaults to {@code "dev"}
|
||||||
|
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param applicationContextError optional error message when the application run context
|
||||||
|
* could not be initialised at startup; {@code null} becomes empty
|
||||||
*/
|
*/
|
||||||
public GuiStartupContext {
|
public GuiStartupContext {
|
||||||
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
|
||||||
startupNotice = startupNotice == null ? Optional.empty() : startupNotice;
|
startupNotice = Objects.requireNonNullElse(startupNotice, Optional.empty());
|
||||||
|
applicationContextError = Objects.requireNonNullElse(applicationContextError, Optional.empty());
|
||||||
configurationFileLoader = Objects.requireNonNull(configurationFileLoader,
|
configurationFileLoader = Objects.requireNonNull(configurationFileLoader,
|
||||||
"configurationFileLoader must not be null");
|
"configurationFileLoader must not be null");
|
||||||
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
|
configurationFileWriter = Objects.requireNonNull(configurationFileWriter,
|
||||||
"configurationFileWriter must not be null");
|
"configurationFileWriter must not be null");
|
||||||
|
modelCatalogPort = Objects.requireNonNull(modelCatalogPort,
|
||||||
|
"modelCatalogPort must not be null");
|
||||||
|
apiKeyResolutionPort = Objects.requireNonNull(apiKeyResolutionPort,
|
||||||
|
"apiKeyResolutionPort must not be null");
|
||||||
|
providerTechnicalTestService = Objects.requireNonNull(providerTechnicalTestService,
|
||||||
|
"providerTechnicalTestService must not be null");
|
||||||
|
pathCheckPort = Objects.requireNonNull(pathCheckPort,
|
||||||
|
"pathCheckPort must not be null");
|
||||||
|
technicalTestOrchestrator = Objects.requireNonNull(technicalTestOrchestrator,
|
||||||
|
"technicalTestOrchestrator must not be null");
|
||||||
|
correctionExecutionService = Objects.requireNonNull(correctionExecutionService,
|
||||||
|
"correctionExecutionService must not be null");
|
||||||
|
batchRunLauncher = Objects.requireNonNull(batchRunLauncher,
|
||||||
|
"batchRunLauncher must not be null");
|
||||||
|
miniRunLauncher = Objects.requireNonNull(miniRunLauncher,
|
||||||
|
"miniRunLauncher must not be null");
|
||||||
|
resetDocumentStatusPort = Objects.requireNonNull(resetDocumentStatusPort,
|
||||||
|
"resetDocumentStatusPort must not be null");
|
||||||
|
manualFileRenamePort = Objects.requireNonNull(manualFileRenamePort,
|
||||||
|
"manualFileRenamePort must not be null");
|
||||||
|
manualFileCopyPort = Objects.requireNonNull(manualFileCopyPort,
|
||||||
|
"manualFileCopyPort must not be null");
|
||||||
|
historicalDocumentContextPort = Objects.requireNonNull(historicalDocumentContextPort,
|
||||||
|
"historicalDocumentContextPort must not be null");
|
||||||
|
// Null-Fallback für Testumgebungen ohne gepacktes JAR
|
||||||
|
applicationVersion = applicationVersion == null ? "dev" : applicationVersion;
|
||||||
|
promptEditorPort = Objects.requireNonNull(promptEditorPort, "promptEditorPort must not be null");
|
||||||
|
historyOverviewPort = Objects.requireNonNull(historyOverviewPort,
|
||||||
|
"historyOverviewPort must not be null");
|
||||||
|
historyDetailsPort = Objects.requireNonNull(historyDetailsPort,
|
||||||
|
"historyDetailsPort must not be null");
|
||||||
|
historyResetDocumentStatusPort = Objects.requireNonNull(historyResetDocumentStatusPort,
|
||||||
|
"historyResetDocumentStatusPort must not be null");
|
||||||
|
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
|
||||||
|
"deleteDocumentHistoryPort must not be null");
|
||||||
|
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
|
||||||
|
"promptEditorPortFactory must not be null");
|
||||||
|
createNewDatabasePort = Objects.requireNonNull(createNewDatabasePort,
|
||||||
|
"createNewDatabasePort must not be null");
|
||||||
|
schedulerControlUseCase = Objects.requireNonNullElse(schedulerControlUseCase, Optional.empty());
|
||||||
|
configurationFileLockPort = Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
|
||||||
|
applicationContextInitializer = applicationContextInitializer == null
|
||||||
|
? GuiApplicationContextInitializer.noOp() : applicationContextInitializer;
|
||||||
|
modelPriceManagementPort = Objects.requireNonNullElse(modelPriceManagementPort, Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a blank startup context with no loader or writer side effects.
|
* Backward-compatible constructor that fills {@code schedulerControlUseCase} with
|
||||||
|
* {@link Optional#empty()}.
|
||||||
|
* <p>
|
||||||
|
* Preserves existing callers that were written before the scheduler was added.
|
||||||
|
*
|
||||||
|
* @param initialState initial editor state; must not be {@code null}
|
||||||
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
|
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||||
|
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
||||||
|
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||||
|
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||||
|
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
|
||||||
|
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||||
|
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||||
|
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||||
|
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
|
||||||
|
* @param miniRunLauncher bridge that executes a targeted mini-run; must not be {@code null}
|
||||||
|
* @param resetDocumentStatusPort bridge that resets document status; must not be {@code null}
|
||||||
|
* @param manualFileRenamePort bridge that renames a target file; must not be {@code null}
|
||||||
|
* @param manualFileCopyPort bridge that copies a source file; must not be {@code null}
|
||||||
|
* @param historicalDocumentContextPort bridge for historical processing context; must not be {@code null}
|
||||||
|
* @param applicationVersion resolved application version string; {@code null} defaults to {@code "dev"}
|
||||||
|
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; must not be {@code null}
|
||||||
|
* @param historyOverviewPort bridge for history overview; must not be {@code null}
|
||||||
|
* @param historyDetailsPort bridge for history details; must not be {@code null}
|
||||||
|
* @param historyResetDocumentStatusPort bridge for history reset; must not be {@code null}
|
||||||
|
* @param deleteDocumentHistoryPort bridge for history deletion; must not be {@code null}
|
||||||
|
* @param promptEditorPortFactory factory for prompt editor ports; must not be {@code null}
|
||||||
|
* @param createNewDatabasePort bridge for new database creation; must not be {@code null}
|
||||||
|
* @param applicationContextError optional error from context init; {@code null} becomes empty
|
||||||
|
*/
|
||||||
|
public GuiStartupContext(
|
||||||
|
GuiConfigurationEditorState initialState,
|
||||||
|
Optional<String> startupNotice,
|
||||||
|
GuiConfigurationFileLoader configurationFileLoader,
|
||||||
|
GuiConfigurationFileWriter configurationFileWriter,
|
||||||
|
AiModelCatalogPort modelCatalogPort,
|
||||||
|
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||||
|
ProviderTechnicalTestService providerTechnicalTestService,
|
||||||
|
PathCheckPort pathCheckPort,
|
||||||
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
|
CorrectionExecutionService correctionExecutionService,
|
||||||
|
GuiBatchRunLauncher batchRunLauncher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
||||||
|
GuiManualFileRenamePort manualFileRenamePort,
|
||||||
|
GuiManualFileCopyPort manualFileCopyPort,
|
||||||
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||||
|
String applicationVersion,
|
||||||
|
GuiPromptEditorPort promptEditorPort,
|
||||||
|
GuiHistoryOverviewPort historyOverviewPort,
|
||||||
|
GuiHistoryDetailsPort historyDetailsPort,
|
||||||
|
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
||||||
|
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
|
||||||
|
GuiPromptEditorPortFactory promptEditorPortFactory,
|
||||||
|
GuiCreateNewDatabasePort createNewDatabasePort,
|
||||||
|
Optional<String> applicationContextError) {
|
||||||
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
|
miniRunLauncher, resetDocumentStatusPort, manualFileRenamePort, manualFileCopyPort,
|
||||||
|
historicalDocumentContextPort, applicationVersion, promptEditorPort,
|
||||||
|
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
|
||||||
|
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
|
||||||
|
applicationContextError, Optional.empty(), Optional.empty(),
|
||||||
|
GuiApplicationContextInitializer.noOp(), Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible constructor that fills {@code configurationFileLockPort} with
|
||||||
|
* {@link Optional#empty()}.
|
||||||
|
* <p>
|
||||||
|
* Preserves existing callers that were written before the configuration file lock port
|
||||||
|
* was added.
|
||||||
|
*
|
||||||
|
* @param initialState initial editor state; must not be {@code null}
|
||||||
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
|
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||||
|
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
||||||
|
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||||
|
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||||
|
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
|
||||||
|
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||||
|
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||||
|
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||||
|
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
|
||||||
|
* @param miniRunLauncher bridge that executes a targeted mini-run; must not be {@code null}
|
||||||
|
* @param resetDocumentStatusPort bridge that resets document status; must not be {@code null}
|
||||||
|
* @param manualFileRenamePort bridge that renames a target file; must not be {@code null}
|
||||||
|
* @param manualFileCopyPort bridge that copies a source file; must not be {@code null}
|
||||||
|
* @param historicalDocumentContextPort bridge for historical processing context; must not be {@code null}
|
||||||
|
* @param applicationVersion resolved application version string; {@code null} defaults to {@code "dev"}
|
||||||
|
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; must not be {@code null}
|
||||||
|
* @param historyOverviewPort bridge for history overview; must not be {@code null}
|
||||||
|
* @param historyDetailsPort bridge for history details; must not be {@code null}
|
||||||
|
* @param historyResetDocumentStatusPort bridge for history reset; must not be {@code null}
|
||||||
|
* @param deleteDocumentHistoryPort bridge for history deletion; must not be {@code null}
|
||||||
|
* @param promptEditorPortFactory factory for prompt editor ports; must not be {@code null}
|
||||||
|
* @param createNewDatabasePort bridge for new database creation; must not be {@code null}
|
||||||
|
* @param applicationContextError optional error from context init; {@code null} becomes empty
|
||||||
|
* @param schedulerControlUseCase optional scheduler control use case; {@code null} becomes empty
|
||||||
|
*/
|
||||||
|
public GuiStartupContext(
|
||||||
|
GuiConfigurationEditorState initialState,
|
||||||
|
Optional<String> startupNotice,
|
||||||
|
GuiConfigurationFileLoader configurationFileLoader,
|
||||||
|
GuiConfigurationFileWriter configurationFileWriter,
|
||||||
|
AiModelCatalogPort modelCatalogPort,
|
||||||
|
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||||
|
ProviderTechnicalTestService providerTechnicalTestService,
|
||||||
|
PathCheckPort pathCheckPort,
|
||||||
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
|
CorrectionExecutionService correctionExecutionService,
|
||||||
|
GuiBatchRunLauncher batchRunLauncher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetDocumentStatusPort,
|
||||||
|
GuiManualFileRenamePort manualFileRenamePort,
|
||||||
|
GuiManualFileCopyPort manualFileCopyPort,
|
||||||
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||||
|
String applicationVersion,
|
||||||
|
GuiPromptEditorPort promptEditorPort,
|
||||||
|
GuiHistoryOverviewPort historyOverviewPort,
|
||||||
|
GuiHistoryDetailsPort historyDetailsPort,
|
||||||
|
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
||||||
|
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
|
||||||
|
GuiPromptEditorPortFactory promptEditorPortFactory,
|
||||||
|
GuiCreateNewDatabasePort createNewDatabasePort,
|
||||||
|
Optional<String> applicationContextError,
|
||||||
|
Optional<SchedulerControlUseCase> schedulerControlUseCase) {
|
||||||
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
|
miniRunLauncher, resetDocumentStatusPort, manualFileRenamePort, manualFileCopyPort,
|
||||||
|
historicalDocumentContextPort, applicationVersion, promptEditorPort,
|
||||||
|
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
|
||||||
|
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
|
||||||
|
applicationContextError, schedulerControlUseCase, Optional.empty(),
|
||||||
|
GuiApplicationContextInitializer.noOp(), Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible constructor that fills the manual-rename port with a no-op
|
||||||
|
* implementation.
|
||||||
|
*
|
||||||
|
* @param initialState initial editor state; must not be {@code null}
|
||||||
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
|
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||||
|
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
||||||
|
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||||
|
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||||
|
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
|
||||||
|
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||||
|
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||||
|
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||||
|
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
|
||||||
|
* @param miniRunLauncher bridge that executes a targeted mini-run for selected
|
||||||
|
* documents; must not be {@code null}
|
||||||
|
* @param resetDocumentStatusPort bridge that resets the persistence status of selected
|
||||||
|
* documents; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public GuiStartupContext(
|
||||||
|
GuiConfigurationEditorState initialState,
|
||||||
|
Optional<String> startupNotice,
|
||||||
|
GuiConfigurationFileLoader configurationFileLoader,
|
||||||
|
GuiConfigurationFileWriter configurationFileWriter,
|
||||||
|
AiModelCatalogPort modelCatalogPort,
|
||||||
|
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||||
|
ProviderTechnicalTestService providerTechnicalTestService,
|
||||||
|
PathCheckPort pathCheckPort,
|
||||||
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
|
CorrectionExecutionService correctionExecutionService,
|
||||||
|
GuiBatchRunLauncher batchRunLauncher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetDocumentStatusPort) {
|
||||||
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
|
miniRunLauncher, resetDocumentStatusPort, rejectingManualFileRenamePort(),
|
||||||
|
rejectingManualFileCopyPort(),
|
||||||
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort(), Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible constructor that fills the mini-run launcher, reset port and
|
||||||
|
* manual-rename port with no-op implementations.
|
||||||
|
*
|
||||||
|
* @param initialState initial editor state; must not be {@code null}
|
||||||
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
|
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||||
|
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
||||||
|
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||||
|
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||||
|
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
|
||||||
|
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||||
|
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||||
|
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||||
|
* @param batchRunLauncher bridge that executes a regular batch run; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public GuiStartupContext(
|
||||||
|
GuiConfigurationEditorState initialState,
|
||||||
|
Optional<String> startupNotice,
|
||||||
|
GuiConfigurationFileLoader configurationFileLoader,
|
||||||
|
GuiConfigurationFileWriter configurationFileWriter,
|
||||||
|
AiModelCatalogPort modelCatalogPort,
|
||||||
|
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||||
|
ProviderTechnicalTestService providerTechnicalTestService,
|
||||||
|
PathCheckPort pathCheckPort,
|
||||||
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
|
CorrectionExecutionService correctionExecutionService,
|
||||||
|
GuiBatchRunLauncher batchRunLauncher) {
|
||||||
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
|
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
|
||||||
|
rejectingMiniRunLauncher(), rejectingResetPort(), rejectingManualFileRenamePort(),
|
||||||
|
rejectingManualFileCopyPort(),
|
||||||
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort(), Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible constructor that fills the processing-run launcher, mini-run
|
||||||
|
* launcher, reset port and manual-rename port with no-op implementations.
|
||||||
|
* <p>
|
||||||
|
* Preserves existing callers that were written before the processing-run tab was added.
|
||||||
|
*
|
||||||
|
* @param initialState initial editor state; must not be {@code null}
|
||||||
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
|
* @param configurationFileLoader file-loading callback; must not be {@code null}
|
||||||
|
* @param configurationFileWriter file-writing callback; must not be {@code null}
|
||||||
|
* @param modelCatalogPort port for retrieving AI model lists; must not be {@code null}
|
||||||
|
* @param apiKeyResolutionPort port for resolving API key provenance; must not be {@code null}
|
||||||
|
* @param providerTechnicalTestService service for provider-specific technical checks; must not be {@code null}
|
||||||
|
* @param pathCheckPort port for filesystem path accessibility checks; must not be {@code null}
|
||||||
|
* @param technicalTestOrchestrator orchestrator for the full technical test run; must not be {@code null}
|
||||||
|
* @param correctionExecutionService service for executing confirmed corrective actions; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public GuiStartupContext(
|
||||||
|
GuiConfigurationEditorState initialState,
|
||||||
|
Optional<String> startupNotice,
|
||||||
|
GuiConfigurationFileLoader configurationFileLoader,
|
||||||
|
GuiConfigurationFileWriter configurationFileWriter,
|
||||||
|
AiModelCatalogPort modelCatalogPort,
|
||||||
|
ApiKeyResolutionPort apiKeyResolutionPort,
|
||||||
|
ProviderTechnicalTestService providerTechnicalTestService,
|
||||||
|
PathCheckPort pathCheckPort,
|
||||||
|
TechnicalTestOrchestrator technicalTestOrchestrator,
|
||||||
|
CorrectionExecutionService correctionExecutionService) {
|
||||||
|
this(initialState, startupNotice, configurationFileLoader, configurationFileWriter,
|
||||||
|
modelCatalogPort, apiKeyResolutionPort, providerTechnicalTestService, pathCheckPort,
|
||||||
|
technicalTestOrchestrator, correctionExecutionService,
|
||||||
|
rejectingBatchRunLauncher(), rejectingMiniRunLauncher(), rejectingResetPort(),
|
||||||
|
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||||
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort(), Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
|
return (configPath, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
|
||||||
|
return (configPath, filter, observer, token) -> GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Kein Mini-Run-Launcher in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiResetDocumentStatusPort rejectingResetPort() {
|
||||||
|
return (configPath, fingerprints) -> {
|
||||||
|
java.util.Map<DocumentFingerprint, String> failures = new java.util.HashMap<>();
|
||||||
|
for (DocumentFingerprint fp : fingerprints) {
|
||||||
|
failures.put(fp, "Kein Reset-Port in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
return new ResetDocumentStatusResult(fingerprints.size(), Set.of(), failures);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiManualFileRenamePort rejectingManualFileRenamePort() {
|
||||||
|
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.ManualFileRenameFileSystemFailure(
|
||||||
|
"Kein Umbenennungs-Port in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiManualFileCopyPort rejectingManualFileCopyPort() {
|
||||||
|
return (configPath, request) -> new de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.ManualFileCopyFileSystemFailure(
|
||||||
|
"Kein Kopier-Port in diesem Startkontext verfügbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
|
||||||
|
return (configPath, fingerprint) -> java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a blank startup context with no-op implementations for all ports and services.
|
||||||
|
* <p>
|
||||||
|
* This is safe for environments where no Bootstrap wiring is present, such as isolated
|
||||||
|
* GUI tests.
|
||||||
*
|
*
|
||||||
* @param startupNotice optional startup notice; {@code null} becomes empty
|
* @param startupNotice optional startup notice; {@code null} becomes empty
|
||||||
* @return a startup context for the unloaded editor start
|
* @return a startup context for the unloaded editor start
|
||||||
*/
|
*/
|
||||||
public static GuiStartupContext blank(Optional<String> startupNotice) {
|
public static GuiStartupContext blank(Optional<String> startupNotice) {
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.AiModelCatalogPort noOpCatalogPort =
|
||||||
|
request -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog
|
||||||
|
.ModelCatalogResult.IncompleteConfiguration(
|
||||||
|
request.providerIdentifier(),
|
||||||
|
"Kein Modellkatalog in diesem Startkontext verfügbar.");
|
||||||
|
ApiKeyResolutionPort noOpApiKeyPort = (family, propertyValue) -> EffectiveApiKeyDescriptor.absent();
|
||||||
|
ProviderTechnicalTestService noOpTestService =
|
||||||
|
new ProviderTechnicalTestService(noOpCatalogPort, noOpApiKeyPort);
|
||||||
|
PathCheckPort noOpPathCheckPort = new PathCheckPort() {
|
||||||
|
@Override
|
||||||
|
public boolean isDirectoryReadable(String path) { return false; }
|
||||||
|
@Override
|
||||||
|
public boolean isDirectoryWritableOrCreatable(String path) { return false; }
|
||||||
|
@Override
|
||||||
|
public boolean isFileReadable(String path) { return false; }
|
||||||
|
@Override
|
||||||
|
public boolean isSqlitePathUsable(String path) { return false; }
|
||||||
|
};
|
||||||
|
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
|
||||||
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
|
noOpPathCheckPort,
|
||||||
|
noOpTestService,
|
||||||
|
() -> java.util.Optional.empty());
|
||||||
|
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.CreateDirectory suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
|
||||||
|
GuiBatchRunLauncher noOpBatchRunLauncher = (configPath, observer, token) ->
|
||||||
|
GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Kein Verarbeitungslauf-Launcher in diesem Startkontext verfügbar.");
|
||||||
return new GuiStartupContext(
|
return new GuiStartupContext(
|
||||||
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
startupNotice,
|
startupNotice,
|
||||||
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
|
configFilePath -> GuiConfigurationEditorStateFactory.createBlankStartState(),
|
||||||
(values, path) -> GuiConfigurationSaveResult.saved(path));
|
(values, path) -> GuiConfigurationSaveResult.saved(path),
|
||||||
|
noOpCatalogPort,
|
||||||
|
noOpApiKeyPort,
|
||||||
|
noOpTestService,
|
||||||
|
noOpPathCheckPort,
|
||||||
|
noOpOrchestrator,
|
||||||
|
noOpCorrectionService,
|
||||||
|
noOpBatchRunLauncher,
|
||||||
|
rejectingMiniRunLauncher(),
|
||||||
|
rejectingResetPort(),
|
||||||
|
rejectingManualFileRenamePort(),
|
||||||
|
rejectingManualFileCopyPort(),
|
||||||
|
noOpHistoricalDocumentContextPort(),
|
||||||
|
"dev",
|
||||||
|
noOpPromptEditorPort(),
|
||||||
|
noOpHistoryOverviewPort(),
|
||||||
|
noOpHistoryDetailsPort(),
|
||||||
|
noOpHistoryResetPort(),
|
||||||
|
noOpDeleteHistoryPort(),
|
||||||
|
noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort(),
|
||||||
|
Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert einen ablehnenden {@link GuiCreateNewDatabasePort}, der jede Anlage
|
||||||
|
* sofort als Fehler zurückgibt. Wird verwendet, wenn kein Bootstrap-seitig
|
||||||
|
* verdrahteter Port vorliegt (z. B. in Tests oder vor dem Laden einer
|
||||||
|
* Konfiguration).
|
||||||
|
*
|
||||||
|
* @return ein ablehnender Port; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static GuiCreateNewDatabasePort rejectingCreateNewDatabasePort() {
|
||||||
|
return (configFilePath, targetFile) -> new de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
||||||
|
"Kein DB-Anlage-Port in diesem Startkontext verfügbar.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
|
||||||
|
return path -> noOpPromptEditorPort();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiPromptEditorPort noOpPromptEditorPort() {
|
||||||
|
return new GuiPromptEditorPort() {
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
|
||||||
|
"NO_OP", NO_PROMPT_PORT_MSG);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
|
||||||
|
NO_PROMPT_PORT_MSG, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
|
createDefaultPromptIfMissing(
|
||||||
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
|
.CorrectionOutcome.NotAttempted(
|
||||||
|
suggestion, NO_PROMPT_PORT_MSG);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort
|
||||||
|
noOpHistoryOverviewPort() {
|
||||||
|
return (configFilePath, query) -> new de.gecheckt.pdf.umbenenner.application.usecase
|
||||||
|
.DefaultHistoryOverviewUseCase.HistoryOverviewResult(java.util.List.of(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort
|
||||||
|
noOpHistoryDetailsPort() {
|
||||||
|
return (configFilePath, fingerprint) -> java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort
|
||||||
|
noOpHistoryResetPort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* kein Reset in diesem Startkontext */ };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort
|
||||||
|
noOpDeleteHistoryPort() {
|
||||||
|
return (configFilePath, fingerprint) -> { /* kein Löschen in diesem Startkontext */ };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.AiProviderFamilyStringConverter;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Separator;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanente Statuszeile am unteren Rand des Hauptfensters.
|
||||||
|
* <p>
|
||||||
|
* Die Statuszeile zeigt immer drei Segmente:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Links:</b> Anwendungsversion im Format {@code V<version>}, z. B. {@code Vdev}.</li>
|
||||||
|
* <li><b>Mitte:</b> Aktiver Provider und Modellname aus der geladenen Konfiguration.</li>
|
||||||
|
* <li><b>Rechts:</b> Pfad der geladenen Konfigurationsdatei.</li>
|
||||||
|
* </ul>
|
||||||
|
* Wenn keine Konfiguration geladen ist, zeigen Mitte und Rechts den Text
|
||||||
|
* {@value #KEIN_PROFIL_TEXT}. Die Versionsanzeige ist stets sichtbar.
|
||||||
|
* <p>
|
||||||
|
* Alle Aktualisierungen dieser Komponente müssen auf dem JavaFX Application Thread erfolgen.
|
||||||
|
* Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung.
|
||||||
|
*/
|
||||||
|
public final class GuiStatusBar {
|
||||||
|
private static final String LABEL_STYLE = "-fx-font-size: 11px; -fx-text-fill: #555555;";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** Anzeigetext wenn keine Konfiguration geladen ist. */
|
||||||
|
static final String KEIN_PROFIL_TEXT = "Kein Profil geladen";
|
||||||
|
|
||||||
|
/** Präfix vor der Versionsnummer in der linken Statuszeilen-Zelle. */
|
||||||
|
private static final String VERSION_PREFIX = "V";
|
||||||
|
|
||||||
|
private static final AiProviderFamilyStringConverter PROVIDER_CONVERTER =
|
||||||
|
new AiProviderFamilyStringConverter();
|
||||||
|
|
||||||
|
private final String applicationVersion;
|
||||||
|
private final BorderPane root;
|
||||||
|
private final Label versionLabel;
|
||||||
|
private final Label providerLabel;
|
||||||
|
private final Label configPathLabel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue Statuszeile mit der angegebenen Anwendungsversion.
|
||||||
|
*
|
||||||
|
* @param applicationVersion die aufgelöste Versionsnummer; {@code null} oder leer führt zum
|
||||||
|
* Fallback {@code "dev"}
|
||||||
|
*/
|
||||||
|
public GuiStatusBar(String applicationVersion) {
|
||||||
|
this.applicationVersion = (applicationVersion == null || applicationVersion.isBlank())
|
||||||
|
? "dev"
|
||||||
|
: applicationVersion;
|
||||||
|
|
||||||
|
// Linkes Segment: Versionsanzeige
|
||||||
|
this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion);
|
||||||
|
this.versionLabel.setStyle(LABEL_STYLE);
|
||||||
|
|
||||||
|
// Mittleres Segment: Provider und Modell
|
||||||
|
this.providerLabel = new Label(KEIN_PROFIL_TEXT);
|
||||||
|
this.providerLabel.setStyle(LABEL_STYLE);
|
||||||
|
this.providerLabel.setAlignment(Pos.CENTER);
|
||||||
|
|
||||||
|
// Rechtes Segment: Konfigurationspfad
|
||||||
|
this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
|
||||||
|
this.configPathLabel.setStyle(LABEL_STYLE);
|
||||||
|
this.configPathLabel.setAlignment(Pos.CENTER_RIGHT);
|
||||||
|
|
||||||
|
// Abstandhalter zwischen den Segmenten
|
||||||
|
Region leftSpacer = new Region();
|
||||||
|
Region rightSpacer = new Region();
|
||||||
|
HBox.setHgrow(leftSpacer, Priority.ALWAYS);
|
||||||
|
HBox.setHgrow(rightSpacer, Priority.ALWAYS);
|
||||||
|
|
||||||
|
HBox content = new HBox(16,
|
||||||
|
versionLabel, leftSpacer,
|
||||||
|
providerLabel, rightSpacer,
|
||||||
|
configPathLabel);
|
||||||
|
content.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
content.setPadding(new Insets(4, 12, 4, 12));
|
||||||
|
content.setStyle("-fx-background-color: #f5f5f5;");
|
||||||
|
|
||||||
|
Separator topSeparator = new Separator();
|
||||||
|
|
||||||
|
this.root = new BorderPane();
|
||||||
|
this.root.setTop(topSeparator);
|
||||||
|
this.root.setCenter(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den Wurzelknoten der Statuszeile zurück, der in das Hauptfenster eingebettet wird.
|
||||||
|
*
|
||||||
|
* @return der Wurzelknoten; nie {@code null}
|
||||||
|
*/
|
||||||
|
public BorderPane root() {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert die Statuszeile anhand des aktuellen Editor-Zustands.
|
||||||
|
* <p>
|
||||||
|
* Ist kein Dateisnapshot vorhanden, wird {@link #clearConfiguration()} ausgeführt.
|
||||||
|
* Andernfalls werden Provider, Modell und Konfigurationspfad aus dem Zustand ermittelt
|
||||||
|
* und angezeigt.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param state der aktuelle Editor-Zustand; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public void applyEditorState(GuiConfigurationEditorState state) {
|
||||||
|
if (state == null || !state.hasLoadedFileSnapshot()) {
|
||||||
|
clearConfiguration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String configPath = state.configurationPathText();
|
||||||
|
String providerText = resolveProviderText(state);
|
||||||
|
providerLabel.setText(providerText);
|
||||||
|
configPathLabel.setText(configPath.isBlank() ? KEIN_PROFIL_TEXT : configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt Mitte und Rechts der Statuszeile auf den Text {@link #KEIN_PROFIL_TEXT} zurück.
|
||||||
|
* <p>
|
||||||
|
* Die Versionsanzeige bleibt unverändert.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void clearConfiguration() {
|
||||||
|
providerLabel.setText(KEIN_PROFIL_TEXT);
|
||||||
|
configPathLabel.setText(KEIN_PROFIL_TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuell angezeigten Versionstext zurück (inkl. Präfix {@code V}).
|
||||||
|
* <p>
|
||||||
|
* Für Tests zugänglich.
|
||||||
|
*
|
||||||
|
* @return der angezeigte Versionstext; nie {@code null}
|
||||||
|
*/
|
||||||
|
String versionText() {
|
||||||
|
return versionLabel.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuell angezeigten Provider-Text zurück.
|
||||||
|
* <p>
|
||||||
|
* Für Tests zugänglich.
|
||||||
|
*
|
||||||
|
* @return der angezeigte Provider-Text; nie {@code null}
|
||||||
|
*/
|
||||||
|
String providerText() {
|
||||||
|
return providerLabel.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuell angezeigten Konfigurationspfad-Text zurück.
|
||||||
|
* <p>
|
||||||
|
* Für Tests zugänglich.
|
||||||
|
*
|
||||||
|
* @return der angezeigte Konfigurationspfad-Text; nie {@code null}
|
||||||
|
*/
|
||||||
|
String configPathText() {
|
||||||
|
return configPathLabel.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt den anzuzeigenden Provider-Text aus dem Editor-Zustand.
|
||||||
|
* <p>
|
||||||
|
* Das Format ist: {@code Provider: <AnzeigeName> · <Modellname>}, wobei der Modellname
|
||||||
|
* weggelassen wird, wenn er leer ist.
|
||||||
|
*
|
||||||
|
* @param state der Editor-Zustand; darf nicht {@code null} sein
|
||||||
|
* @return der formatierte Provider-Text; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static String resolveProviderText(GuiConfigurationEditorState state) {
|
||||||
|
String activeIdentifier = state.values().activeProviderFamily();
|
||||||
|
if (activeIdentifier == null || activeIdentifier.isBlank()) {
|
||||||
|
return KEIN_PROFIL_TEXT;
|
||||||
|
}
|
||||||
|
AiProviderFamily family = AiProviderFamily.fromIdentifier(activeIdentifier).orElse(null);
|
||||||
|
if (family == null) {
|
||||||
|
return KEIN_PROFIL_TEXT;
|
||||||
|
}
|
||||||
|
String displayName = PROVIDER_CONVERTER.toString(family);
|
||||||
|
GuiProviderConfigurationState providerState = state.values().providerConfiguration(family);
|
||||||
|
String model = providerState != null ? providerState.model() : "";
|
||||||
|
if (model == null || model.isBlank()) {
|
||||||
|
return "Provider: " + displayName;
|
||||||
|
}
|
||||||
|
return "Provider: " + displayName + " · " + model;
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
|
||||||
|
import javafx.animation.Animation;
|
||||||
|
import javafx.animation.KeyFrame;
|
||||||
|
import javafx.animation.Timeline;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Status-Refresh-Timeline für die GUI.
|
||||||
|
* <p>
|
||||||
|
* Startet eine JavaFX-{@link Timeline}, die im Sekundentakt einen Callback aufruft.
|
||||||
|
* Der Callback liest den aktuellen Scheduler-Status und aktualisiert alle betroffenen
|
||||||
|
* Tabs (Batch-Tab, Konfig-Tab, Scheduler-Tab) auf dem JavaFX Application Thread.
|
||||||
|
* <p>
|
||||||
|
* Die Timeline wird beim Aufbau der Haupt-GUI gestartet und beim Beenden der
|
||||||
|
* Anwendung gestoppt. Sie läuft unabhängig davon, welcher Tab gerade sichtbar ist.
|
||||||
|
* <p>
|
||||||
|
* Wenn kein {@link SchedulerControlUseCase} vorhanden ist, wird der Callback trotzdem
|
||||||
|
* aufgerufen – der Aufrufer entscheidet, wie er das leere Optional behandelt.
|
||||||
|
*/
|
||||||
|
public final class GuiStatusRefreshTimeline {
|
||||||
|
|
||||||
|
private final Timeline timeline;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine neue Status-Refresh-Timeline.
|
||||||
|
* <p>
|
||||||
|
* Die Timeline ist nach der Konstruktion noch nicht aktiv; {@link #start()} muss
|
||||||
|
* explizit aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param schedulerControlUseCase optionaler Scheduler-Control-Use-Case;
|
||||||
|
* {@code null} wird als leer behandelt
|
||||||
|
* @param onRefresh Callback der bei jedem Tick auf dem JavaFX Application
|
||||||
|
* Thread aufgerufen wird; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public GuiStatusRefreshTimeline(
|
||||||
|
Optional<SchedulerControlUseCase> schedulerControlUseCase,
|
||||||
|
Runnable onRefresh) {
|
||||||
|
Objects.requireNonNull(onRefresh, "onRefresh must not be null");
|
||||||
|
this.timeline = new Timeline(
|
||||||
|
new KeyFrame(Duration.seconds(1), e -> onRefresh.run()));
|
||||||
|
this.timeline.setCycleCount(Animation.INDEFINITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet die Status-Refresh-Timeline.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
* Mehrfache Aufrufe sind unschädlich.
|
||||||
|
*/
|
||||||
|
public void start() {
|
||||||
|
timeline.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt die Status-Refresh-Timeline.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
* Mehrfache Aufrufe sind unschädlich.
|
||||||
|
*/
|
||||||
|
public void stop() {
|
||||||
|
timeline.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorValidationInput;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointId;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestOrchestrator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestReport;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.TechnicalTestRequest;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Koordiniert die asynchrone Ausführung der Aktion „Technische Tests ausführen"
|
||||||
|
* für den GUI-Konfigurationseditor.
|
||||||
|
* <p>
|
||||||
|
* Dieser Koordinator ist verantwortlich für:
|
||||||
|
* <ul>
|
||||||
|
* <li>Lesen des aktuellen GUI-Editorzustands (via {@link Supplier}).</li>
|
||||||
|
* <li>Aufbau eines {@link TechnicalTestRequest} aus dem aktuellen Zustand.</li>
|
||||||
|
* <li>Ausführung des {@link TechnicalTestOrchestrator} auf einem dedizierten
|
||||||
|
* Daemon-Hinterground-Thread namens {@code gui-technical-test}.</li>
|
||||||
|
* <li>Rückführung des {@link TechnicalTestReport} auf den JavaFX Application Thread
|
||||||
|
* via {@code Platform.runLater}.</li>
|
||||||
|
* <li>Einhängen der Ergebnisse als {@link GuiMessageEntry}-Einträge in die geteilte
|
||||||
|
* {@code pendingMessages}-Liste (Quelle: „Technische-Tests").</li>
|
||||||
|
* <li>Ersetzen vorheriger Test-Einträge (Replace-Semantik) bei jedem neuen Aufruf.</li>
|
||||||
|
* <li>Weitergabe des vollständigen Berichts an den {@code postResultCallback}, damit
|
||||||
|
* spätere Arbeitsschritte (z. B. Korrekturhilfen) auf das Ergebnis zugreifen können.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading-Kontrakt:</strong> {@link #triggerTechnicalTests()} darf nur auf dem
|
||||||
|
* JavaFX Application Thread aufgerufen werden. Hintergrund-Worker-Threads dürfen nur über
|
||||||
|
* den injizierten {@code resultDelivery}-Verbraucher mit der UI interagieren, der in der
|
||||||
|
* Produktion {@code Platform.runLater} kapselt.
|
||||||
|
* <p>
|
||||||
|
* <strong>Kein implizites Speichern:</strong> Der Koordinator liest den aktuellen GUI-Zustand
|
||||||
|
* und führt den Test aus, ohne die Konfigurationsdatei zu schreiben oder den Dirty-Zustand
|
||||||
|
* des Editors zu ändern.
|
||||||
|
* <p>
|
||||||
|
* <strong>Anti-Scope:</strong> Dieser Koordinator führt keine schreibenden Korrekturen durch.
|
||||||
|
* Korrekturvorschläge werden als Bestandteil des {@link TechnicalTestReport} zurückgegeben,
|
||||||
|
* sind aber nicht ausführbar. Die Ausführung ist einem späteren Arbeitsschritt vorbehalten.
|
||||||
|
* <p>
|
||||||
|
* Die Worker-Thread-Factory und die Result-Delivery-Funktion sind injizierbar, damit Tests
|
||||||
|
* deterministisch ohne echten Hintergrund-Thread laufen können.
|
||||||
|
*/
|
||||||
|
public final class GuiTechnicalTestCoordinator {
|
||||||
|
|
||||||
|
/** Quell-Tag für Einträge in {@code pendingMessages}, die von diesem Koordinator stammen. */
|
||||||
|
static final String SOURCE_TAG = "Technische-Tests";
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiTechnicalTestCoordinator.class);
|
||||||
|
|
||||||
|
private final TechnicalTestOrchestrator orchestrator;
|
||||||
|
private final Supplier<EditorValidationInput> inputProvider;
|
||||||
|
private final Supplier<String> configFilePathProvider;
|
||||||
|
private final Supplier<String> logDirectoryProvider;
|
||||||
|
private final List<GuiMessageEntry> pendingMessages;
|
||||||
|
private final Consumer<TechnicalTestReport> postResultCallback;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory für den Hintergrund-Worker-Thread. Paket-privat für Test-Substitution.
|
||||||
|
* Standard: Daemon-Thread namens {@code gui-technical-test}.
|
||||||
|
*/
|
||||||
|
Function<Runnable, Thread> testThreadFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbraucher zur Rückführung des Ergebnisses. In der Produktion kapselt er {@code Platform.runLater}.
|
||||||
|
* In Tests kann er durch einen direkten Aufruf ersetzt werden, damit das Ergebnis sofort
|
||||||
|
* auf dem Worker-Thread angewendet wird, ohne die FX-Warteschlange zu entwässern.
|
||||||
|
* Paket-privat für Test-Substitution.
|
||||||
|
*/
|
||||||
|
java.util.function.Consumer<Runnable> resultDelivery = Platform::runLater;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Koordinator.
|
||||||
|
*
|
||||||
|
* @param orchestrator Orchestrator für den vollständigen Gesamttest; darf nicht {@code null} sein
|
||||||
|
* @param inputProvider Lieferant des aktuellen {@link EditorValidationInput}; darf nicht {@code null} sein
|
||||||
|
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
|
||||||
|
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param logDirectoryProvider Lieferant des konfigurierten {@code log.directory}-Rohwerts;
|
||||||
|
* gibt eine leere Zeichenkette zurück wenn kein Wert konfiguriert ist;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
|
||||||
|
* @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
|
||||||
|
Supplier<EditorValidationInput> inputProvider,
|
||||||
|
Supplier<String> configFilePathProvider,
|
||||||
|
Supplier<String> logDirectoryProvider,
|
||||||
|
List<GuiMessageEntry> pendingMessages,
|
||||||
|
Consumer<TechnicalTestReport> postResultCallback) {
|
||||||
|
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
|
||||||
|
this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider must not be null");
|
||||||
|
this.configFilePathProvider = Objects.requireNonNull(configFilePathProvider, "configFilePathProvider must not be null");
|
||||||
|
this.logDirectoryProvider = Objects.requireNonNull(logDirectoryProvider, "logDirectoryProvider must not be null");
|
||||||
|
this.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages must not be null");
|
||||||
|
this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
|
||||||
|
this.testThreadFactory = task -> {
|
||||||
|
Thread t = new Thread(task, "gui-technical-test");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst die asynchrone Ausführung des vollständigen technischen Gesamttests aus.
|
||||||
|
* <p>
|
||||||
|
* Vor dem Worker-Start wird die geteilte Nachrichtenliste auf dem FX-Thread geleert;
|
||||||
|
* jeder Aufruf ersetzt die zuvor angefügten Einträge (Replace-Semantik).
|
||||||
|
* <p>
|
||||||
|
* Liest den aktuellen Editorzustand und den Konfigurationsdateipfad, baut einen
|
||||||
|
* {@link TechnicalTestRequest} und startet den {@link TechnicalTestOrchestrator} auf
|
||||||
|
* einem Hintergrund-Worker-Thread. Das Ergebnis wird via {@code resultDelivery} an den
|
||||||
|
* JavaFX Application Thread zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Der Konfigurationsdateipfad wird genutzt, um bei fehlender Prompt-Datei-Konfiguration
|
||||||
|
* einen sinnvollen Standardpfad ({@code <config-parent>/prompt.txt}) zu bestimmen.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void triggerTechnicalTests() {
|
||||||
|
// Bestehende Nachrichtenliste auf dem FX-Thread leeren, bevor der Worker-Thread
|
||||||
|
// startet. Dadurch laufen clear() und nachfolgende add()-Aufrufe (die per
|
||||||
|
// Platform.runLater wieder auf dem FX-Thread landen) auf demselben Thread und
|
||||||
|
// es entsteht kein Race-Fenster mit der UI.
|
||||||
|
pendingMessages.clear();
|
||||||
|
EditorValidationInput input = inputProvider.get();
|
||||||
|
String configFilePath = configFilePathProvider.get();
|
||||||
|
String logDirectory = logDirectoryProvider.get();
|
||||||
|
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath, logDirectory);
|
||||||
|
|
||||||
|
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
|
||||||
|
|
||||||
|
Runnable task = () -> {
|
||||||
|
TechnicalTestReport report = orchestrator.run(request);
|
||||||
|
resultDelivery.accept(() -> {
|
||||||
|
applyResult(report);
|
||||||
|
postResultCallback.accept(report);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Thread worker = testThreadFactory.apply(task);
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet das Ergebnis des vollständigen Gesamttests auf die geteilte Nachrichtenliste an.
|
||||||
|
* <p>
|
||||||
|
* Fügt für jedes Checkpoint-Ergebnis einen neuen Eintrag zur geteilten Nachrichtenliste
|
||||||
|
* hinzu. Die Liste wurde zuvor in {@link #triggerTechnicalTests()} geleert, sodass jeder
|
||||||
|
* Aufruf einen frischen Stand erzeugt. Zusätzlich wird eine Zusammenfassung angehängt.
|
||||||
|
* <p>
|
||||||
|
* Muss nur auf dem JavaFX Application Thread aufgerufen werden (via {@code resultDelivery}).
|
||||||
|
*
|
||||||
|
* @param report der vollständige Gesamttestbericht; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
private void applyResult(TechnicalTestReport report) {
|
||||||
|
|
||||||
|
long successCount = 0;
|
||||||
|
long failureErrorCount = 0;
|
||||||
|
long failureWarnCount = 0;
|
||||||
|
long notApplicableCount = 0;
|
||||||
|
|
||||||
|
for (CheckpointResult result : report.results()) {
|
||||||
|
String label = labelFor(result.checkpointId());
|
||||||
|
switch (result) {
|
||||||
|
case CheckpointResult.Success success -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.INFO,
|
||||||
|
label + ": OK – " + success.message(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
successCount++;
|
||||||
|
LOG.info("GUI-Gesamttest: {} → OK", label);
|
||||||
|
}
|
||||||
|
case CheckpointResult.Failure failure -> {
|
||||||
|
GuiMessageSeverity severity = failure.severity() ==
|
||||||
|
de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CheckpointSeverity.ERROR
|
||||||
|
? GuiMessageSeverity.ERROR : GuiMessageSeverity.WARNING;
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
severity,
|
||||||
|
label + ": " + failure.message(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
if (severity == GuiMessageSeverity.ERROR) {
|
||||||
|
failureErrorCount++;
|
||||||
|
LOG.warn("GUI-Gesamttest: {} → FEHLER: {}", label, failure.message());
|
||||||
|
} else {
|
||||||
|
failureWarnCount++;
|
||||||
|
LOG.warn("GUI-Gesamttest: {} → WARNUNG: {}", label, failure.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case CheckpointResult.NotApplicable notApplicable -> {
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.HINT,
|
||||||
|
label + ": nicht anwendbar – " + notApplicable.reason(),
|
||||||
|
SOURCE_TAG));
|
||||||
|
notApplicableCount++;
|
||||||
|
LOG.info("GUI-Gesamttest: {} → nicht anwendbar: {}", label, notApplicable.reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusammenfassung
|
||||||
|
long totalFindings = failureErrorCount + failureWarnCount;
|
||||||
|
String summary = buildSummaryMessage(report.results().size(),
|
||||||
|
successCount, failureErrorCount, failureWarnCount, notApplicableCount);
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, summary, SOURCE_TAG));
|
||||||
|
LOG.info("GUI-Gesamttest abgeschlossen. {} Befunde ({} Erfolg, {} Fehler, {} Warnung, {} nicht anwendbar).",
|
||||||
|
totalFindings, successCount, failureErrorCount, failureWarnCount, notApplicableCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das deutsche Label für einen Prüfpunkt zurück.
|
||||||
|
*
|
||||||
|
* @param id der Prüfpunkt-Bezeichner; darf nicht {@code null} sein
|
||||||
|
* @return das deutsche Label; nie {@code null}
|
||||||
|
*/
|
||||||
|
static String labelFor(CheckpointId id) {
|
||||||
|
return switch (id) {
|
||||||
|
case CONFIGURATION_BASIC_VALIDATION -> "Konfiguration grundsätzlich validierbar";
|
||||||
|
case PROVIDER_CONFIGURATION -> "Provider-Konfiguration prüfbar";
|
||||||
|
case BASE_URL_REACHABLE -> "Basis-URL/Endpoint erreichbar";
|
||||||
|
case API_KEY_PRESENT -> "API-Schlüssel vorhanden";
|
||||||
|
case API_KEY_ACCEPTED -> "API-Schlüssel technisch akzeptiert";
|
||||||
|
case MODEL_LIST_AVAILABLE -> "Modellliste abrufbar";
|
||||||
|
case SELECTED_MODEL_PLAUSIBLE -> "Ausgewähltes Modell plausibel";
|
||||||
|
case PROMPT_FILE_PRESENT -> "Prompt-Datei vorhanden und lesbar";
|
||||||
|
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
|
||||||
|
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
|
||||||
|
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
|
||||||
|
case LOG_DIRECTORY_USABLE -> "Log-Verzeichnis beschreibbar";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die deutsche Zusammenfassungsmeldung des Gesamttests.
|
||||||
|
*
|
||||||
|
* @param total Gesamtzahl der Prüfpunkte
|
||||||
|
* @param successCount Anzahl der erfolgreichen Prüfpunkte
|
||||||
|
* @param errorCount Anzahl der fehlgeschlagenen Prüfpunkte (Schweregrad ERROR)
|
||||||
|
* @param warningCount Anzahl der Warnungs-Prüfpunkte
|
||||||
|
* @param notApplicable Anzahl der nicht-anwendbaren Prüfpunkte
|
||||||
|
* @return deutsche Zusammenfassungsmeldung; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static String buildSummaryMessage(long total, long successCount, long errorCount,
|
||||||
|
long warningCount, long notApplicable) {
|
||||||
|
long findings = errorCount + warningCount;
|
||||||
|
String base = "Gesamttest abgeschlossen. " + total + " Prüfpunkte: "
|
||||||
|
+ successCount + " Erfolg, "
|
||||||
|
+ errorCount + " Fehler, "
|
||||||
|
+ warningCount + " Warnung, "
|
||||||
|
+ notApplicable + " nicht anwendbar.";
|
||||||
|
if (findings == 0) {
|
||||||
|
return base + " Keine Befunde.";
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt eine unveränderliche Momentaufnahme der aktuell ausstehenden Nachrichten zurück.
|
||||||
|
* <p>
|
||||||
|
* Ausschließlich für Tests gedacht.
|
||||||
|
*
|
||||||
|
* @return unveränderliche Kopie der Nachrichtenliste; nie {@code null}
|
||||||
|
*/
|
||||||
|
public List<GuiMessageEntry> pendingMessagesSnapshot() {
|
||||||
|
return List.copyOf(pendingMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Konstantenklasse für alle Tooltip-Texte der GUI.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse ist die einzige autoritative Quelle für Tooltip-Beschriftungen aller
|
||||||
|
* interaktiven Elemente in der Desktop-Oberfläche. Alle Tooltip-Strings werden hier
|
||||||
|
* definiert und von den jeweiligen UI-Klassen referenziert. Streustrings im
|
||||||
|
* UI-Code sind unzulässig.
|
||||||
|
* <p>
|
||||||
|
* Tooltip-Texte für Status-Icons werden <em>nicht</em> hier gepflegt – sie stammen
|
||||||
|
* ausschließlich aus {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation},
|
||||||
|
* die die autoritative Quelle für alle statusbezogenen Darstellungsinformationen ist.
|
||||||
|
* <p>
|
||||||
|
* Alle Texte sind deutschsprachig gemäß Spezifikation.
|
||||||
|
* Diese Klasse enthält keine JavaFX-Typen und ist nicht instanziierbar.
|
||||||
|
*/
|
||||||
|
public final class GuiTooltipTexts {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Toolbar-Buttons
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Neu". */
|
||||||
|
public static final String TOOLBAR_NEU =
|
||||||
|
"Neue Konfiguration erstellen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Öffnen". */
|
||||||
|
public static final String TOOLBAR_OEFFNEN =
|
||||||
|
"Bestehende Konfigurationsdatei (.properties) öffnen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Speichern". */
|
||||||
|
public static final String TOOLBAR_SPEICHERN =
|
||||||
|
"Aktuelle Konfiguration speichern.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Speichern unter". */
|
||||||
|
public static final String TOOLBAR_SPEICHERN_UNTER =
|
||||||
|
"Konfiguration unter neuem Dateipfad speichern.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Validieren". */
|
||||||
|
public static final String TOOLBAR_VALIDIEREN =
|
||||||
|
"Aktuelle Eingaben auf Vollständigkeit und Korrektheit prüfen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Technische Tests ausführen". */
|
||||||
|
public static final String TOOLBAR_TECHNISCHE_TESTS =
|
||||||
|
"Dateipfade, Datenbankverbindung und KI-Erreichbarkeit prüfen.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Konfigurationstab – Pfade
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Quellordner". */
|
||||||
|
public static final String PFADE_QUELLORDNER =
|
||||||
|
"Ordner mit den zu verarbeitenden PDF-Dateien. Inhalt wird nicht verändert.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Zielordner". */
|
||||||
|
public static final String PFADE_ZIELORDNER =
|
||||||
|
"Ordner für die umbenannten Kopien.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „SQLite-Datei". */
|
||||||
|
public static final String PFADE_SQLITE =
|
||||||
|
"Datenbank für Verarbeitungsergebnisse und Datei-Historie.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Prompt-Datei". */
|
||||||
|
public static final String PFADE_PROMPT =
|
||||||
|
"Externe Textdatei mit den KI-Anweisungen.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Lock-Datei". */
|
||||||
|
public static final String PFADE_LOCK_DATEI =
|
||||||
|
"Pfad zur Lock-Datei, die parallele Instanzen verhindert (optional).";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Log-Verzeichnis". */
|
||||||
|
public static final String PFADE_LOG_VERZEICHNIS =
|
||||||
|
"Verzeichnis für Log-Dateien. Leer = Standardverzeichnis logs/ im Programmverzeichnis.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Konfigurationstab – Provider
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für die Provider-ComboBox. */
|
||||||
|
public static final String PROVIDER_COMBOBOX =
|
||||||
|
"Der KI-Dienst, der die Dateinamen generiert.";
|
||||||
|
|
||||||
|
/** Tooltip für das Modell-Eingabefeld (ComboBox oder manuelles TextField). */
|
||||||
|
public static final String PROVIDER_MODELL =
|
||||||
|
"Das konkrete Sprachmodell des gewählten Providers.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Basis-URL". */
|
||||||
|
public static final String PROVIDER_BASIS_URL =
|
||||||
|
"Basis-URL des KI-Dienstes (z. B. https://api.openai.com/v1).";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Timeout". */
|
||||||
|
public static final String PROVIDER_TIMEOUT =
|
||||||
|
"Zeitlimit für KI-Anfragen in Sekunden.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „API-Key". */
|
||||||
|
public static final String PROVIDER_API_KEY =
|
||||||
|
"API-Schlüssel für den konfigurierten KI-Dienst. Umgebungsvariable hat Vorrang.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Konfigurationstab – Verarbeitungslimits
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „max.text.characters". */
|
||||||
|
public static final String LIMITS_MAX_TEXT_CHARACTERS =
|
||||||
|
"Maximale Zeichenzahl aus dem PDF-Text. Höhere Werte = mehr Kontext, höhere Kosten.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „max.pages". */
|
||||||
|
public static final String LIMITS_MAX_PAGES =
|
||||||
|
"Maximale Seitenzahl, die aus einem PDF gelesen wird.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „max.title.length". */
|
||||||
|
public static final String LIMITS_MAX_TITLE_LENGTH =
|
||||||
|
"Maximale Länge des Dateinamens in Zeichen (ohne Datum und Erweiterung). Gültig: 10–120.";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „max.retries.transient". */
|
||||||
|
public static final String LIMITS_MAX_RETRIES =
|
||||||
|
"Maximale Anzahl transienter Wiederholversuche je Dokument (Ganzzahl ≥ 1).";
|
||||||
|
|
||||||
|
/** Tooltip für das Eingabefeld „Log-Level". */
|
||||||
|
public static final String LIMITS_LOG_LEVEL =
|
||||||
|
"Log-Detailstufe (z. B. INFO, DEBUG, WARN). Leer = Standardwert INFO.";
|
||||||
|
|
||||||
|
/** Tooltip für die Checkbox „Sensible KI-Ausgabe". */
|
||||||
|
public static final String LIMITS_SENSIBLE_KI_AUSGABE =
|
||||||
|
"Vollständige KI-Antworten in die Log-Datei schreiben (nur für Diagnosezwecke empfohlen).";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Verarbeitungslauf-Tab – Dateiname-Editor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für das Dateiname-Textfeld im Dateiname-Editor. */
|
||||||
|
public static final String DATEINAME_TEXTFELD =
|
||||||
|
"Dateiname bearbeiten. Format: JJJJ-MM-TT - Titel.pdf";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Dateiname übernehmen". */
|
||||||
|
public static final String DATEINAME_UEBERNEHMEN =
|
||||||
|
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Zurücksetzen auf KI-Vorschlag". */
|
||||||
|
public static final String DATEINAME_ZURUECKSETZEN =
|
||||||
|
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Verarbeitungslauf-Tab – Laufsteuerung und Tabelle
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Starten". */
|
||||||
|
public static final String BATCHRUN_STARTEN =
|
||||||
|
"Verarbeitungslauf starten: alle ausstehenden PDF-Dateien aus dem Quellordner verarbeiten.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Abbrechen". */
|
||||||
|
public static final String BATCHRUN_ABBRECHEN =
|
||||||
|
"Laufenden Verarbeitungslauf abbrechen. Bereits abgeschlossene Dateien bleiben gespeichert.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Erneut verarbeiten". */
|
||||||
|
public static final String BATCHRUN_ERNEUT_VERARBEITEN =
|
||||||
|
"Markierte Einträge erneut zur Verarbeitung freigeben (setzt Status auf READY_FOR_AI).";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Status zurücksetzen" im Verarbeitungslauf-Tab. */
|
||||||
|
public static final String BATCHRUN_STATUS_ZURUECKSETZEN =
|
||||||
|
"Status der markierten Einträge zurücksetzen, damit sie beim nächsten Lauf verarbeitet werden.";
|
||||||
|
|
||||||
|
/** Tooltip für die Master-Checkbox im Tabellenkopf des Verarbeitungslauf-Tabs. */
|
||||||
|
public static final String BATCHRUN_MASTER_CHECKBOX =
|
||||||
|
"Alle sichtbaren Einträge markieren oder Markierung aufheben.";
|
||||||
|
|
||||||
|
/** Tooltip für den Meldungsbereich im Verarbeitungslauf-Tab. */
|
||||||
|
public static final String BATCHRUN_MESSAGE_AREA =
|
||||||
|
"Statusmeldungen und Fortschrittsinformationen des aktuellen Verarbeitungslaufs.";
|
||||||
|
|
||||||
|
/** Tooltip für den Navigations-Button „Vorherige Seite" in der PDF-Vorschau. */
|
||||||
|
public static final String PREVIEW_VORHERIGE_SEITE =
|
||||||
|
"Vorherige Seite der Vorschau anzeigen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Navigations-Button „Nächste Seite" in der PDF-Vorschau. */
|
||||||
|
public static final String PREVIEW_NAECHSTE_SEITE =
|
||||||
|
"Nächste Seite der Vorschau anzeigen.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Status" in der Verarbeitungslauf-Tabelle. */
|
||||||
|
public static final String BATCHRUN_COL_STATUS =
|
||||||
|
"Verarbeitungsergebnis: Erfolg, Fehler oder übersprungen.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Originaldateiname" in der Verarbeitungslauf-Tabelle. */
|
||||||
|
public static final String BATCHRUN_COL_ORIGINALDATEINAME =
|
||||||
|
"Ursprünglicher Dateiname der verarbeiteten PDF-Datei.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Neuer Dateiname" in der Verarbeitungslauf-Tabelle. */
|
||||||
|
public static final String BATCHRUN_COL_NEUER_DATEINAME =
|
||||||
|
"Von der KI vorgeschlagener, normierter Dateiname.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Datum" in der Verarbeitungslauf-Tabelle. */
|
||||||
|
public static final String BATCHRUN_COL_DATUM =
|
||||||
|
"Datum des Dokuments laut KI-Analyse.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Dauer" in der Verarbeitungslauf-Tabelle. */
|
||||||
|
public static final String BATCHRUN_COL_DAUER =
|
||||||
|
"Verarbeitungsdauer für diese Datei.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Verlauf-Tab – Detailbereich
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für den KI-Begründungs-Bereich im Verlauf-Tab. */
|
||||||
|
public static final String VERLAUF_REASONING_AREA =
|
||||||
|
"KI-Begründung des ausgewählten Verarbeitungsversuchs.";
|
||||||
|
|
||||||
|
/** Tooltip für den Fehlerursachen-Bereich im Verlauf-Tab. */
|
||||||
|
public static final String VERLAUF_FAILURE_AREA =
|
||||||
|
"Fehlermeldung des letzten Fehler-Versuchs für dieses Dokument.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Status" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_COL_STATUS =
|
||||||
|
"Aktueller Gesamtstatus des Dokuments.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Quelldatei" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_COL_QUELLDATEI =
|
||||||
|
"Ursprünglicher Dateiname der PDF-Quelldatei.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Zieldatei" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_COL_ZIELDATEI =
|
||||||
|
"Vom System erzeugter, normierter Dateiname im Zielordner.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Letzter Versuch" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_COL_LETZTER_VERSUCH =
|
||||||
|
"Zeitpunkt des zuletzt abgeschlossenen Verarbeitungsversuchs.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Versuche" in der Übersichtstabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_COL_VERSUCHE =
|
||||||
|
"Gesamtanzahl der Verarbeitungsversuche für dieses Dokument.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „#" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_VERSUCHE_COL_NR =
|
||||||
|
"Laufende Nummer des Verarbeitungsversuchs.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Datum" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_VERSUCHE_COL_DATUM =
|
||||||
|
"Endzeitpunkt dieses Verarbeitungsversuchs.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Status" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_VERSUCHE_COL_STATUS =
|
||||||
|
"Ergebnis dieses Verarbeitungsversuchs.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Provider" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_VERSUCHE_COL_PROVIDER =
|
||||||
|
"KI-Provider, der für diesen Versuch verwendet wurde.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Modell" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_VERSUCHE_COL_MODELL =
|
||||||
|
"Konkretes Sprachmodell, das für diesen Versuch verwendet wurde.";
|
||||||
|
|
||||||
|
/** Tooltip für Spalte „Vorgeschlagener Name" in der Versuche-Tabelle des Verlauf-Tabs. */
|
||||||
|
public static final String VERLAUF_VERSUCHE_COL_VORGESCHLAGENER_NAME =
|
||||||
|
"Vom System erzeugter Zieldateiname für diesen Versuch.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Konfigurations-Tab – Meldungsbereich und Modell-Neu-Laden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Meldungen leeren". */
|
||||||
|
public static final String TOOLBAR_MELDUNGEN_LEEREN =
|
||||||
|
"Alle Meldungen im Meldungsbereich entfernen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Modelle neu laden". */
|
||||||
|
public static final String PROVIDER_MODELLE_NEU_LADEN =
|
||||||
|
"Verfügbare Modelle vom konfigurierten Provider neu abrufen.";
|
||||||
|
|
||||||
|
/** Tooltip für den Ordner-/Datei-Browser-Button. */
|
||||||
|
public static final String PFADE_BROWSER_BUTTON =
|
||||||
|
"Ordner oder Datei über den Datei-Dialog auswählen.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Prompt-Tab – Textbereich
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für den Prompt-Textbereich im Prompt-Editor-Tab. */
|
||||||
|
public static final String PROMPT_TEXTAREA =
|
||||||
|
"KI-Anweisungstext. Dieser Prompt wird bei jedem Verarbeitungsversuch an das Sprachmodell gesendet.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Speichern" im Prompt-Editor-Tab. */
|
||||||
|
public static final String PROMPT_SPEICHERN =
|
||||||
|
"Prompt-Datei speichern (atomar, UTF-8).";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Auf Standard zurücksetzen" im Prompt-Editor-Tab. */
|
||||||
|
public static final String PROMPT_ZURUECKSETZEN =
|
||||||
|
"Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern.";
|
||||||
|
|
||||||
|
/** Tooltip für den Button „Standard-Prompt erstellen" im Prompt-Editor-Tab. */
|
||||||
|
public static final String PROMPT_STANDARD_ANLEGEN =
|
||||||
|
"Standard-Prompt-Datei am konfigurierten Pfad anlegen.";
|
||||||
|
|
||||||
|
/** Nicht instanziierbar – reine Konstantenklasse. */
|
||||||
|
private GuiTooltipTexts() {
|
||||||
|
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
-10
@@ -1,12 +1,21 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
import javafx.application.Application;
|
|
||||||
import javafx.scene.Scene;
|
|
||||||
import javafx.stage.Stage;
|
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import javafx.application.Application;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.event.EventHandler;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.Menu;
|
||||||
|
import javafx.scene.control.MenuBar;
|
||||||
|
import javafx.scene.control.MenuItem;
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.stage.Stage;
|
||||||
|
import javafx.stage.WindowEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JavaFX application entry point for the PDF-Umbenenner GUI inbound adapter.
|
* JavaFX application entry point for the PDF-Umbenenner GUI inbound adapter.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -18,6 +27,13 @@ import org.apache.logging.log4j.Logger;
|
|||||||
* {@code titleUpdateListener} hook. The close-request handler is installed through
|
* {@code titleUpdateListener} hook. The close-request handler is installed through
|
||||||
* {@link GuiConfigurationEditorWorkspace#installCloseRequestHandler(Stage)} so that
|
* {@link GuiConfigurationEditorWorkspace#installCloseRequestHandler(Stage)} so that
|
||||||
* unsaved changes are protected when the user tries to close the window.
|
* unsaved changes are protected when the user tries to close the window.
|
||||||
|
*
|
||||||
|
* <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert.
|
||||||
|
* Über das Tray-Kontextmenü kann das Fenster wieder geöffnet oder die Anwendung beendet werden.
|
||||||
|
*
|
||||||
|
* <p>Nach dem Anzeigen des Hauptfensters startet eine zentrale {@link GuiStatusRefreshTimeline}
|
||||||
|
* (1 Hz), die den aktuellen Scheduler-Status liest und alle betroffenen Tabs aktualisiert.
|
||||||
|
* Die Timeline wird beim Beenden der Anwendung gestoppt.
|
||||||
*/
|
*/
|
||||||
public class PdfUmbenennerGuiApplication extends Application {
|
public class PdfUmbenennerGuiApplication extends Application {
|
||||||
|
|
||||||
@@ -25,6 +41,11 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
private static final double DEFAULT_WIDTH = 1100;
|
private static final double DEFAULT_WIDTH = 1100;
|
||||||
private static final double DEFAULT_HEIGHT = 800;
|
private static final double DEFAULT_HEIGHT = 800;
|
||||||
|
|
||||||
|
private SystemTrayManager trayManager;
|
||||||
|
private GuiConfigurationEditorWorkspace workspace;
|
||||||
|
private GuiStartupContext guiStartupContext;
|
||||||
|
private GuiStatusRefreshTimeline refreshTimeline;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new instance of the JavaFX application.
|
* Creates a new instance of the JavaFX application.
|
||||||
*/
|
*/
|
||||||
@@ -35,9 +56,12 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
/**
|
/**
|
||||||
* Initializes and shows the primary stage.
|
* Initializes and shows the primary stage.
|
||||||
* <p>
|
* <p>
|
||||||
|
* Lädt die Anwendungs-Icons in allen verfügbaren Größen und setzt sie am Fenster.
|
||||||
* Wires the workspace title-update listener to the stage title so any dirty-state change
|
* Wires the workspace title-update listener to the stage title so any dirty-state change
|
||||||
* causes an immediate window-title refresh. Also installs the close-request handler that
|
* causes an immediate window-title refresh. Installs the close-request handler that
|
||||||
* guards unsaved changes before the window is closed.
|
* guards unsaved changes and minimizes the window to the system tray instead of closing.
|
||||||
|
* <p>
|
||||||
|
* Startet nach dem Anzeigen des Fensters die zentrale Status-Refresh-Timeline.
|
||||||
*
|
*
|
||||||
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
|
* @param primaryStage the primary stage provided by the JavaFX runtime; never {@code null}
|
||||||
*/
|
*/
|
||||||
@@ -45,31 +69,179 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
public void start(Stage primaryStage) {
|
public void start(Stage primaryStage) {
|
||||||
LOG.info("GUI: JavaFX-Oberfläche wird initialisiert.");
|
LOG.info("GUI: JavaFX-Oberfläche wird initialisiert.");
|
||||||
|
|
||||||
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
|
// Anwendungs-Icons laden; JavaFX wählt je nach Kontext automatisch die passende Größe
|
||||||
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
|
primaryStage.getIcons().addAll(
|
||||||
|
new Image(getClass().getResourceAsStream("/icons/Icon16.png")),
|
||||||
|
new Image(getClass().getResourceAsStream("/icons/Icon32.png")),
|
||||||
|
new Image(getClass().getResourceAsStream("/icons/Icon64.png")),
|
||||||
|
new Image(getClass().getResourceAsStream("/icons/Icon128.png"))
|
||||||
|
);
|
||||||
|
|
||||||
|
guiStartupContext = GuiStartupContextHolder.currentOrBlank();
|
||||||
|
workspace = new GuiConfigurationEditorWorkspace(guiStartupContext);
|
||||||
|
|
||||||
// Wire the title-update listener so the stage title stays in sync with the dirty state.
|
// Wire the title-update listener so the stage title stays in sync with the dirty state.
|
||||||
workspace.titleUpdateListener = primaryStage::setTitle;
|
workspace.titleUpdateListener = primaryStage::setTitle;
|
||||||
|
|
||||||
Scene scene = new Scene(workspace.root(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
|
// Statuszeile anlegen und mit dem Workspace verdrahten
|
||||||
|
GuiStatusBar statusBar = new GuiStatusBar(guiStartupContext.applicationVersion());
|
||||||
|
workspace.statusBarStateListener = statusBar::applyEditorState;
|
||||||
|
|
||||||
|
// Menüleiste mit Datenbank-Menü („Neue Datenbank anlegen…")
|
||||||
|
MenuBar menuBar = buildMenuBar(workspace);
|
||||||
|
|
||||||
|
// Statuszeile unterhalb des Workspace-Inhalts einbetten
|
||||||
|
BorderPane outerLayout = new BorderPane();
|
||||||
|
outerLayout.setTop(menuBar);
|
||||||
|
outerLayout.setCenter(workspace.root());
|
||||||
|
outerLayout.setBottom(statusBar.root());
|
||||||
|
|
||||||
|
Scene scene = new Scene(outerLayout, DEFAULT_WIDTH, DEFAULT_HEIGHT);
|
||||||
primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
|
primaryStage.setTitle(GuiWindowTitleFormatter.format(workspace.editorState()));
|
||||||
primaryStage.setScene(scene);
|
primaryStage.setScene(scene);
|
||||||
|
|
||||||
// Install the close-request handler that protects unsaved changes.
|
// Install the close-request handler that protects unsaved changes.
|
||||||
workspace.installCloseRequestHandler(primaryStage);
|
workspace.installCloseRequestHandler(primaryStage);
|
||||||
|
|
||||||
|
// System-Tray aktivieren: JavaFX-Runtime nicht beenden wenn Fenster versteckt wird
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
trayManager = new SystemTrayManager(primaryStage);
|
||||||
|
if (trayManager.install()) {
|
||||||
|
installTrayCloseHandler(primaryStage, workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler-Close-Guard als äußerste Schicht: verhindert Beenden während Scheduler aktiv
|
||||||
|
installSchedulerCloseGuard(primaryStage);
|
||||||
|
|
||||||
|
primaryStage.setMaximized(true);
|
||||||
primaryStage.show();
|
primaryStage.show();
|
||||||
|
|
||||||
|
// Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden.
|
||||||
|
workspace.autoLoadLastConfiguration();
|
||||||
|
|
||||||
|
// Zentrale Status-Refresh-Timeline starten (1 Hz)
|
||||||
|
refreshTimeline = new GuiStatusRefreshTimeline(
|
||||||
|
guiStartupContext.schedulerControlUseCase(),
|
||||||
|
this::refreshAllTabStates);
|
||||||
|
refreshTimeline.start();
|
||||||
|
|
||||||
LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
|
LOG.info("GUI: Hauptfenster erfolgreich angezeigt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by the JavaFX runtime when the application is stopping.
|
* Called by the JavaFX runtime when the application is stopping.
|
||||||
* <p>
|
* <p>
|
||||||
* Logs the GUI shutdown event. No additional cleanup is required.
|
* Stoppt die Status-Refresh-Timeline, entfernt das System-Tray-Icon und loggt das Beenden.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void stop() {
|
public void stop() {
|
||||||
LOG.info("GUI: JavaFX-Anwendung wird beendet.");
|
LOG.info("GUI: JavaFX-Anwendung wird beendet.");
|
||||||
|
if (refreshTimeline != null) {
|
||||||
|
refreshTimeline.stop();
|
||||||
|
}
|
||||||
|
if (trayManager != null) {
|
||||||
|
trayManager.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den aktuellen Scheduler-Status und aktualisiert alle betroffenen Tabs.
|
||||||
|
* <p>
|
||||||
|
* Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem JavaFX
|
||||||
|
* Application Thread aufgerufen. Wenn kein {@link SchedulerControlUseCase} vorhanden
|
||||||
|
* ist, wird der Aufruf ohne Fehler übersprungen.
|
||||||
|
*/
|
||||||
|
private void refreshAllTabStates() {
|
||||||
|
// Den Use Case nicht aus dem unveränderlichen GuiStartupContext lesen, sondern
|
||||||
|
// den zur Laufzeit (z. B. durch Auto-Load) verdrahteten Use Case verwenden.
|
||||||
|
// Andernfalls bliebe der Stop-Button dauerhaft deaktiviert, weil updateStatus
|
||||||
|
// nie aufgerufen würde.
|
||||||
|
workspace.refreshSchedulerStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Menüleiste für das Hauptfenster auf.
|
||||||
|
* <p>
|
||||||
|
* Aktuell enthält sie genau einen Eintrag: das Menü „Datenbank" mit der Aktion
|
||||||
|
* „Neue Datenbank anlegen…". Diese delegiert an
|
||||||
|
* {@link GuiConfigurationEditorWorkspace#requestCreateNewDatabase()}.
|
||||||
|
* <p>
|
||||||
|
* Der Menüpunkt ist deaktiviert, solange ein Verarbeitungslauf aktiv ist oder
|
||||||
|
* bereits eine DB-Anlage läuft. Die Reaktivierung erfolgt automatisch, sobald
|
||||||
|
* der Workspace die DB-Busy-Sperre wieder aufhebt.
|
||||||
|
*
|
||||||
|
* @param workspace der Workspace, an den die Aktionen delegieren; nie {@code null}
|
||||||
|
* @return die fertig konfigurierte Menüleiste
|
||||||
|
*/
|
||||||
|
private MenuBar buildMenuBar(GuiConfigurationEditorWorkspace workspace) {
|
||||||
|
Menu databaseMenu = new Menu("Datenbank");
|
||||||
|
MenuItem createNewItem = new MenuItem("Neue Datenbank anlegen…");
|
||||||
|
createNewItem.setOnAction(event -> workspace.requestCreateNewDatabase());
|
||||||
|
// Sperre während eines aktiven Verarbeitungslaufs oder einer laufenden DB-Anlage
|
||||||
|
createNewItem.disableProperty().bind(workspace.batchRunRunningProperty()
|
||||||
|
.or(workspace.dbBusyForDatabaseCreationProperty()));
|
||||||
|
databaseMenu.getItems().add(createNewItem);
|
||||||
|
return new MenuBar(databaseMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt den Scheduler-Close-Guard als äußerste Schicht des Close-Request-Handlers an.
|
||||||
|
* <p>
|
||||||
|
* Ist kein {@link de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase}
|
||||||
|
* vorhanden, bleibt der bestehende Handler unverändert. Ist der Scheduler aktiv
|
||||||
|
* (Zustand != {@code STOPPED}), wird das Schließen verhindert und ein
|
||||||
|
* Informationsdialog angezeigt. Ist der Scheduler gestoppt, wird der bisherige
|
||||||
|
* Handler (SystemTray + Workspace-Dirty-Guard) aufgerufen.
|
||||||
|
*
|
||||||
|
* @param stage das primäre Fenster; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
private void installSchedulerCloseGuard(Stage stage) {
|
||||||
|
EventHandler<WindowEvent> existingHandler = stage.getOnCloseRequest();
|
||||||
|
stage.setOnCloseRequest(event -> {
|
||||||
|
// Use Case dynamisch über den Workspace lesen, weil der Scheduler erst
|
||||||
|
// nach erfolgreichem Datei-Öffnen (z. B. Auto-Load) verdrahtet wird und
|
||||||
|
// damit nicht zwingend im unveränderlichen GuiStartupContext steht.
|
||||||
|
if (workspace.isSchedulerActive()) {
|
||||||
|
event.consume();
|
||||||
|
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||||
|
alert.setTitle("Anwendung kann nicht beendet werden");
|
||||||
|
alert.setHeaderText(null);
|
||||||
|
alert.setContentText(
|
||||||
|
"Ein Lauf ist aktiv oder der Scheduler läuft.\n"
|
||||||
|
+ "Bitte beende den Scheduler bzw. warte auf das Ende des Laufs.");
|
||||||
|
alert.showAndWait();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existingHandler != null) {
|
||||||
|
existingHandler.handle(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
|
||||||
|
* System-Tray minimiert statt es zu schließen.
|
||||||
|
* <p>
|
||||||
|
* Der vom Workspace installierte Handler wird dabei vorrangig aufgerufen. Nur wenn
|
||||||
|
* er das Event nicht konsumiert (sauberer Zustand, keine laufenden Operationen),
|
||||||
|
* greift dieser Handler und versteckt das Fenster.
|
||||||
|
*
|
||||||
|
* @param stage das primäre Fenster
|
||||||
|
* @param workspace der Workspace-Handler, der bereits installiert wurde
|
||||||
|
*/
|
||||||
|
private void installTrayCloseHandler(Stage stage, GuiConfigurationEditorWorkspace workspace) {
|
||||||
|
EventHandler<WindowEvent> workspaceHandler = stage.getOnCloseRequest();
|
||||||
|
stage.setOnCloseRequest(event -> {
|
||||||
|
// Workspace-Handler zuerst: prüft Dirty-State, laufende Operationen usw.
|
||||||
|
if (workspaceHandler != null) {
|
||||||
|
workspaceHandler.handle(event);
|
||||||
|
}
|
||||||
|
// Wurde das Event nicht konsumiert, ist der Zustand sauber: Fenster in Tray verstecken
|
||||||
|
if (!event.isConsumed()) {
|
||||||
|
event.consume();
|
||||||
|
LOG.info("GUI: Fenster wird in den System-Tray minimiert.");
|
||||||
|
stage.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
|
import java.awt.AWTException;
|
||||||
|
import java.awt.MenuItem;
|
||||||
|
import java.awt.PopupMenu;
|
||||||
|
import java.awt.SystemTray;
|
||||||
|
import java.awt.TrayIcon;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.stage.Stage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet das Windows System-Tray-Icon für den PDF-Umbenenner.
|
||||||
|
* <p>
|
||||||
|
* Wird das Hauptfenster geschlossen, bleibt die Anwendung im Hintergrund aktiv und zeigt
|
||||||
|
* ein Tray-Icon in der Windows-Taskleiste. Über das Kontextmenü kann das Fenster wieder
|
||||||
|
* geöffnet oder die Anwendung vollständig beendet werden.
|
||||||
|
* <p>
|
||||||
|
* Alle Stage-Operationen werden auf dem JavaFX Application Thread ausgeführt, da AWT-Events
|
||||||
|
* auf dem AWT Event Dispatch Thread eintreffen.
|
||||||
|
*/
|
||||||
|
class SystemTrayManager {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(SystemTrayManager.class);
|
||||||
|
|
||||||
|
private final Stage stage;
|
||||||
|
private TrayIcon trayIcon;
|
||||||
|
private boolean installed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen {@code SystemTrayManager} für die angegebene Stage.
|
||||||
|
*
|
||||||
|
* @param stage das primäre Fenster; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
SystemTrayManager(Stage stage) {
|
||||||
|
this.stage = stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installiert das System-Tray-Icon.
|
||||||
|
* <p>
|
||||||
|
* Schlägt die Installation fehl (System-Tray nicht unterstützt oder Icon-Bild nicht ladbar),
|
||||||
|
* wird {@code false} zurückgegeben und kein Tray-Icon angezeigt.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn das Icon erfolgreich installiert wurde, sonst {@code false}
|
||||||
|
*/
|
||||||
|
boolean install() {
|
||||||
|
if (!SystemTray.isSupported()) {
|
||||||
|
LOG.warn("GUI: System-Tray wird auf diesem System nicht unterstützt.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
BufferedImage image = loadTrayImage();
|
||||||
|
if (image == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
PopupMenu menu = buildContextMenu();
|
||||||
|
trayIcon = new TrayIcon(image, "PDF-Umbenenner", menu);
|
||||||
|
trayIcon.setImageAutoSize(true);
|
||||||
|
// Doppelklick öffnet das Fenster
|
||||||
|
trayIcon.addActionListener(e -> Platform.runLater(this::showWindow));
|
||||||
|
try {
|
||||||
|
SystemTray.getSystemTray().add(trayIcon);
|
||||||
|
installed = true;
|
||||||
|
LOG.info("GUI: System-Tray-Icon erfolgreich installiert.");
|
||||||
|
return true;
|
||||||
|
} catch (AWTException e) {
|
||||||
|
LOG.warn("GUI: System-Tray-Icon konnte nicht installiert werden: {}", e.getMessage(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt das Tray-Icon aus dem System-Tray.
|
||||||
|
* Ist kein Icon installiert, wird der Aufruf ignoriert.
|
||||||
|
*/
|
||||||
|
void remove() {
|
||||||
|
if (installed && trayIcon != null) {
|
||||||
|
SystemTray.getSystemTray().remove(trayIcon);
|
||||||
|
installed = false;
|
||||||
|
LOG.info("GUI: System-Tray-Icon entfernt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob das Tray-Icon aktiv installiert ist.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn das Icon im System-Tray sichtbar ist
|
||||||
|
*/
|
||||||
|
boolean isInstalled() {
|
||||||
|
return installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BufferedImage loadTrayImage() {
|
||||||
|
try (InputStream stream = getClass().getResourceAsStream("/icons/Icon16.png")) {
|
||||||
|
if (stream == null) {
|
||||||
|
LOG.warn("GUI: Tray-Icon-Ressource '/icons/Icon16.png' nicht gefunden.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ImageIO.read(stream);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warn("GUI: Tray-Icon-Bild konnte nicht geladen werden: {}", e.getMessage(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PopupMenu buildContextMenu() {
|
||||||
|
PopupMenu menu = new PopupMenu();
|
||||||
|
|
||||||
|
MenuItem openItem = new MenuItem("Öffnen");
|
||||||
|
openItem.addActionListener(e -> Platform.runLater(this::showWindow));
|
||||||
|
|
||||||
|
MenuItem exitItem = new MenuItem("Beenden");
|
||||||
|
exitItem.addActionListener(e -> {
|
||||||
|
remove();
|
||||||
|
Platform.exit();
|
||||||
|
System.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.add(openItem);
|
||||||
|
menu.addSeparator();
|
||||||
|
menu.add(exitItem);
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showWindow() {
|
||||||
|
stage.show();
|
||||||
|
stage.toFront();
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersetzt strukturierte Fehlermeldungen aus der Anwendungsschicht in
|
||||||
|
* benutzerfreundliche deutsche Texte für die Darstellungsschicht der GUI.
|
||||||
|
* <p>
|
||||||
|
* Die Klasse wertet die englischsprachige Fehlermeldung aus dem Verarbeitungsversuch
|
||||||
|
* musterbasiert aus und liefert eine für den Endbenutzer lesbare Beschreibung des
|
||||||
|
* Fehlergrunds. Das ursprüngliche Datenmodell bleibt unverändert; die Übersetzung
|
||||||
|
* findet ausschließlich in der Darstellungsschicht statt.
|
||||||
|
* <p>
|
||||||
|
* Die Mustererkennung erfolgt ohne Berücksichtigung der Groß-/Kleinschreibung
|
||||||
|
* und prüft die definierten Schlüsselbegriffe in festgelegter Reihenfolge,
|
||||||
|
* damit spezifischere Muster vor allgemeineren greifen.
|
||||||
|
* <p>
|
||||||
|
* Die Klasse wird sowohl im Verarbeitungslauf-Tab als auch im Verlauf-Tab verwendet.
|
||||||
|
*/
|
||||||
|
public final class AiFailureMessageTranslator {
|
||||||
|
|
||||||
|
private AiFailureMessageTranslator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine benutzerfreundliche deutsche Fehlermeldung für die angegebene
|
||||||
|
* technische Fehlerbeschreibung.
|
||||||
|
* <p>
|
||||||
|
* Ist {@code technicalMessage} {@code null} oder leer, wird der allgemeine
|
||||||
|
* Fallback-Text zurückgegeben.
|
||||||
|
*
|
||||||
|
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
|
||||||
|
* @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol
|
||||||
|
*/
|
||||||
|
public static String translate(String technicalMessage) {
|
||||||
|
if (technicalMessage == null || technicalMessage.isBlank()) {
|
||||||
|
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
|
||||||
|
}
|
||||||
|
String lower = technicalMessage.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
|
||||||
|
// Pre-Check-Fehler: kein lesbarer Text im PDF
|
||||||
|
if (lower.contains("no usable text")) {
|
||||||
|
return "PDF enthält keinen lesbaren Text. Möglicherweise handelt es sich um einen Scan"
|
||||||
|
+ " ohne Texterkennung (OCR). Eine automatische Benennung ist nicht möglich.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// KI-Validierungsfehler: Titel überschreitet die konfigurierte Maximallänge
|
||||||
|
if (lower.contains("title exceeds")) {
|
||||||
|
return buildTitleExceedsMessage(technicalMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defekte oder strukturell nicht lesbare PDF-Datei
|
||||||
|
if (lower.contains("content not extractable")
|
||||||
|
|| lower.contains("ioexception")
|
||||||
|
|| lower.contains("end of file")
|
||||||
|
|| lower.contains("endoffileexception")
|
||||||
|
|| lower.contains("eof")) {
|
||||||
|
return "Die PDF-Datei ist ungültig oder beschädigt und kann nicht verarbeitet werden.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP-Authentifizierungsfehler
|
||||||
|
if (lower.contains("http_401")) {
|
||||||
|
return "KI-Dienst: Ungültiger API-Schlüssel. Bitte in den Einstellungen prüfen.";
|
||||||
|
}
|
||||||
|
if (lower.contains("http_403")) {
|
||||||
|
return "KI-Dienst: Zugriff verweigert. Bitte API-Schlüssel und Berechtigungen prüfen.";
|
||||||
|
}
|
||||||
|
if (lower.contains("http_429")) {
|
||||||
|
return "KI-Dienst: Anfragelimit erreicht. Bitte später erneut versuchen.";
|
||||||
|
}
|
||||||
|
if (lower.contains("http_5")) {
|
||||||
|
return "KI-Dienst vorübergehend nicht erreichbar. Bitte später erneut versuchen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netzwerk- und Verbindungsfehler
|
||||||
|
if (lower.contains("connection") || lower.contains("timeout") || lower.contains("refused")) {
|
||||||
|
return "KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut aus einer „Title exceeds"-Fehlermeldung einen benutzerfreundlichen Text,
|
||||||
|
* der Titel, tatsächliche Länge und konfiguriertes Limit nennt.
|
||||||
|
* <p>
|
||||||
|
* Erwartet wird das Format:
|
||||||
|
* {@code … Title exceeds N characters (base title): 'Titel' …}
|
||||||
|
* <p>
|
||||||
|
* Kann das Format nicht geparst werden, wird ein generischer Hinweis zurückgegeben.
|
||||||
|
*
|
||||||
|
* @param technicalMessage die vollständige technische Fehlermeldung
|
||||||
|
* @return benutzerfreundlicher Hinweis auf den zu langen Titel
|
||||||
|
*/
|
||||||
|
private static String buildTitleExceedsMessage(String technicalMessage) {
|
||||||
|
try {
|
||||||
|
int exceedsIdx = technicalMessage.indexOf("Title exceeds ");
|
||||||
|
if (exceedsIdx >= 0) {
|
||||||
|
String afterExceeds = technicalMessage.substring(exceedsIdx + "Title exceeds ".length());
|
||||||
|
int charIdx = afterExceeds.indexOf(" characters");
|
||||||
|
if (charIdx > 0) {
|
||||||
|
int limit = Integer.parseInt(afterExceeds.substring(0, charIdx).trim());
|
||||||
|
int colonQuote = technicalMessage.indexOf(": '", exceedsIdx);
|
||||||
|
if (colonQuote >= 0) {
|
||||||
|
String afterQuote = technicalMessage.substring(colonQuote + 3);
|
||||||
|
int closingQuote = afterQuote.lastIndexOf("'");
|
||||||
|
if (closingQuote > 0) {
|
||||||
|
String title = afterQuote.substring(0, closingQuote);
|
||||||
|
return "KI-Vorschlag abgelehnt: '" + title + "' ist zu lang ("
|
||||||
|
+ title.length() + " Zeichen, Limit: " + limit
|
||||||
|
+ "). Bitte Dateinamen manuell kürzen.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException | StringIndexOutOfBoundsException ignored) {
|
||||||
|
// Fallback unten
|
||||||
|
}
|
||||||
|
return "KI-Vorschlag abgelehnt: Titel überschreitet die maximale Länge. Bitte Dateinamen manuell kürzen.";
|
||||||
|
}
|
||||||
|
}
|
||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs
|
||||||
|
* die aggregierten Ergebnisse anzeigt.
|
||||||
|
*
|
||||||
|
* <p>Das Banner erscheint nach Laufabschluss unterhalb des Fortschrittsbalkens und
|
||||||
|
* oberhalb der Ergebnistabelle. Es zeigt nur Kategorien, deren Zähler größer als null
|
||||||
|
* ist. Folgende Status werden nicht gezählt und tauchen nie im Banner auf:
|
||||||
|
* {@code READY_FOR_AI}, {@code PROPOSAL_READY} und {@code PROCESSING} sind im
|
||||||
|
* Enum {@link DocumentCompletionStatus} nicht enthalten – alle enthaltenen Werte
|
||||||
|
* werden gezählt, außer Einträgen mit {@code resetPending=true}, da diese keinen
|
||||||
|
* abgeschlossenen Zustand darstellen.
|
||||||
|
*
|
||||||
|
* <p>Farbe ist niemals das einzige Unterscheidungsmerkmal: Jedes Segment enthält
|
||||||
|
* ein Icon und einen Text.
|
||||||
|
*
|
||||||
|
* <p>Die öffentlichen Methoden {@link #clear()} und {@link #update(Map)} sind
|
||||||
|
* thread-agnostisch definiert, aber müssen auf dem JavaFX Application Thread aufgerufen
|
||||||
|
* werden (oder das Banner muss via {@code Platform.runLater} aktualisiert werden).
|
||||||
|
* Die Aggregations-Hilfsmethode {@link #aggregateCounts(Iterable)} ist vollständig
|
||||||
|
* unabhängig von JavaFX und kann auf jedem Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public final class BatchRunSummaryBanner {
|
||||||
|
|
||||||
|
/** Trennzeichen zwischen den Kategoriesegmenten. */
|
||||||
|
private static final String SEGMENT_SEPARATOR = " · ";
|
||||||
|
|
||||||
|
/** Abstand zwischen den Label-Segmenten in Pixeln. */
|
||||||
|
private static final int SPACING = 0;
|
||||||
|
|
||||||
|
/** Innerer Abstand des Containers in Pixeln (oben/unten). */
|
||||||
|
private static final double PADDING_V = 4.0;
|
||||||
|
|
||||||
|
/** Standardfarbe für den Summentext. */
|
||||||
|
private static final String STYLE_DEFAULT = "-fx-font-size: 12;";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle {@link DocumentCompletionStatus}-Werte, die im Banner angezeigt werden,
|
||||||
|
* in der verbindlichen Anzeigereihenfolge gemäß Spezifikation.
|
||||||
|
*/
|
||||||
|
private static final List<DocumentCompletionStatus> DISPLAYED_ORDER = List.of(
|
||||||
|
DocumentCompletionStatus.SUCCESS,
|
||||||
|
DocumentCompletionStatus.FAILED_RETRYABLE,
|
||||||
|
DocumentCompletionStatus.FAILED_PERMANENT,
|
||||||
|
DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED,
|
||||||
|
DocumentCompletionStatus.SKIPPED_FINAL_FAILURE
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Wurzel-Container des Banners – wird in das Tab-Layout eingebettet. */
|
||||||
|
private final HBox container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertikale Halterung fuer mehrzeilige Banner-Inhalte.
|
||||||
|
*
|
||||||
|
* <p>Enthaelt die Status-Zeile (bestehend), die Token-Zeile, die
|
||||||
|
* Kosten-Zeile und optional eine Cache-only-Zeile. Die Token-/Kosten-
|
||||||
|
* /Cache-Zeilen werden in V3.3 mit einem leeren {@link BatchRunTokenSummary}
|
||||||
|
* vorbelegt; die echten Aggregat-Werte werden durch das nachfolgende
|
||||||
|
* Arbeitspaket geliefert.
|
||||||
|
*/
|
||||||
|
private final VBox lineContainer;
|
||||||
|
|
||||||
|
/** Label, das den kompletten Bannertext als Inline-Segmente trägt. */
|
||||||
|
private final Label contentLabel;
|
||||||
|
|
||||||
|
/** Label fuer die Tokenzeile (Input/Output). */
|
||||||
|
private final Label tokenLabel;
|
||||||
|
|
||||||
|
/** Label fuer die Kosten-Zeile. */
|
||||||
|
private final Label costLabel;
|
||||||
|
|
||||||
|
/** Label fuer die optionale Cache-only-Hinweiszeile. */
|
||||||
|
private final Label cacheOnlyLabel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein neues, initial unsichtbares Summary-Banner.
|
||||||
|
*/
|
||||||
|
public BatchRunSummaryBanner() {
|
||||||
|
contentLabel = new Label();
|
||||||
|
contentLabel.setStyle(STYLE_DEFAULT);
|
||||||
|
contentLabel.setWrapText(false);
|
||||||
|
|
||||||
|
tokenLabel = new Label();
|
||||||
|
tokenLabel.setStyle(STYLE_DEFAULT);
|
||||||
|
costLabel = new Label();
|
||||||
|
costLabel.setStyle(STYLE_DEFAULT);
|
||||||
|
cacheOnlyLabel = new Label();
|
||||||
|
cacheOnlyLabel.setStyle(STYLE_DEFAULT);
|
||||||
|
cacheOnlyLabel.setVisible(false);
|
||||||
|
cacheOnlyLabel.setManaged(false);
|
||||||
|
|
||||||
|
lineContainer = new VBox(2, contentLabel, tokenLabel, costLabel, cacheOnlyLabel);
|
||||||
|
lineContainer.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
|
||||||
|
container = new HBox(SPACING, lineContainer);
|
||||||
|
container.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;");
|
||||||
|
|
||||||
|
// Initial unsichtbar, nimmt keinen Platz ein
|
||||||
|
container.setVisible(false);
|
||||||
|
container.setManaged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Öffentliche API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versteckt das Banner und leert seinen Inhalt.
|
||||||
|
*
|
||||||
|
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void clear() {
|
||||||
|
contentLabel.setText("");
|
||||||
|
tokenLabel.setText("");
|
||||||
|
costLabel.setText("");
|
||||||
|
cacheOnlyLabel.setText("");
|
||||||
|
cacheOnlyLabel.setVisible(false);
|
||||||
|
cacheOnlyLabel.setManaged(false);
|
||||||
|
container.setVisible(false);
|
||||||
|
container.setManaged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert das Banner mit den aggregierten Zählern und macht es sichtbar.
|
||||||
|
*
|
||||||
|
* <p>Zeigt nur Kategorien mit Anzahl > 0. Wenn alle Zähler null sind (leerer Lauf),
|
||||||
|
* wird das Banner versteckt.
|
||||||
|
*
|
||||||
|
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param counts Zuordnung von Verarbeitungsstatus zu Anzahl;
|
||||||
|
* fehlende Status werden als 0 interpretiert; darf nicht null sein
|
||||||
|
*/
|
||||||
|
public void update(Map<DocumentCompletionStatus, Integer> counts) {
|
||||||
|
update(counts, BatchRunTokenSummary.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert das Banner mit Status-Zaehlern und Token-/Kosten-Aggregaten.
|
||||||
|
*
|
||||||
|
* <p>Zeigt die Status-Zeile (wenn nicht leer) sowie die Token- und Kosten-
|
||||||
|
* Zeilen. Die Cache-only-Zeile erscheint nur, wenn {@link
|
||||||
|
* BatchRunTokenSummary#cacheOnlyAttemptCount()} groesser als 0 ist.
|
||||||
|
*
|
||||||
|
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param counts Zaehler je Status; nicht {@code null}
|
||||||
|
* @param tokenSummary Aggregat-Werte fuer Tokens und Kosten; nicht {@code null}
|
||||||
|
*/
|
||||||
|
public void update(Map<DocumentCompletionStatus, Integer> counts,
|
||||||
|
BatchRunTokenSummary tokenSummary) {
|
||||||
|
Objects.requireNonNull(counts, "counts darf nicht null sein");
|
||||||
|
Objects.requireNonNull(tokenSummary, "tokenSummary darf nicht null sein");
|
||||||
|
|
||||||
|
String text = buildBannerText(counts);
|
||||||
|
contentLabel.setText(text);
|
||||||
|
|
||||||
|
tokenLabel.setText(buildTokenLine(tokenSummary));
|
||||||
|
costLabel.setText(buildCostLine(tokenSummary));
|
||||||
|
|
||||||
|
if (tokenSummary.cacheOnlyAttemptCount() > 0) {
|
||||||
|
cacheOnlyLabel.setText(buildCacheOnlyLine(tokenSummary.cacheOnlyAttemptCount()));
|
||||||
|
cacheOnlyLabel.setVisible(true);
|
||||||
|
cacheOnlyLabel.setManaged(true);
|
||||||
|
} else {
|
||||||
|
cacheOnlyLabel.setText("");
|
||||||
|
cacheOnlyLabel.setVisible(false);
|
||||||
|
cacheOnlyLabel.setManaged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.isEmpty() && !tokenSummary.hasAnyData()) {
|
||||||
|
clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.setVisible(true);
|
||||||
|
container.setManaged(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token-/Kosten-Aggregat fuer einen Banner-Eintrag.
|
||||||
|
*
|
||||||
|
* <p>Ein {@link #empty()}-Default reicht, solange das Read-Model fuer
|
||||||
|
* Aggregate noch nicht implementiert ist. AP-B liefert spaeter die echten
|
||||||
|
* Werte ueber den {@code TokenStatisticsReadModelPort}.
|
||||||
|
*
|
||||||
|
* @param totalInputTokens Summe Input-Tokens; ggf. {@code 0}
|
||||||
|
* @param totalOutputTokens Summe Output-Tokens; ggf. {@code 0}
|
||||||
|
* @param totalCostUsd Summe der Kosten in USD; ggf. {@code BigDecimal.ZERO}
|
||||||
|
* @param hasMissingPriceSnapshot {@code true}, wenn mind. ein Versuch ohne Preis-Snapshot vorlag
|
||||||
|
* @param hasCacheTokensIgnored {@code true}, wenn Cache-Tokens vorkamen
|
||||||
|
* @param cacheOnlyAttemptCount Anzahl Cache-only-Versuche im Lauf
|
||||||
|
*/
|
||||||
|
public record BatchRunTokenSummary(
|
||||||
|
long totalInputTokens,
|
||||||
|
long totalOutputTokens,
|
||||||
|
BigDecimal totalCostUsd,
|
||||||
|
boolean hasMissingPriceSnapshot,
|
||||||
|
boolean hasCacheTokensIgnored,
|
||||||
|
long cacheOnlyAttemptCount) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert ein leeres Aggregat (alle Zaehler null, Kosten 0).
|
||||||
|
*
|
||||||
|
* @return leeres Aggregat
|
||||||
|
*/
|
||||||
|
public static BatchRunTokenSummary empty() {
|
||||||
|
return new BatchRunTokenSummary(0L, 0L, BigDecimal.ZERO, false, false, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pruefung, ob ueberhaupt Daten zum Anzeigen vorliegen.
|
||||||
|
*
|
||||||
|
* @return {@code true} bei Werten ungleich 0
|
||||||
|
*/
|
||||||
|
public boolean hasAnyData() {
|
||||||
|
return totalInputTokens > 0 || totalOutputTokens > 0
|
||||||
|
|| (totalCostUsd != null && totalCostUsd.signum() != 0)
|
||||||
|
|| cacheOnlyAttemptCount > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildTokenLine(BatchRunTokenSummary s) {
|
||||||
|
return String.format(Locale.GERMAN, "Tokens: Input %,d Output %,d",
|
||||||
|
s.totalInputTokens(), s.totalOutputTokens());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildCostLine(BatchRunTokenSummary s) {
|
||||||
|
BigDecimal cost = s.totalCostUsd() != null ? s.totalCostUsd() : BigDecimal.ZERO;
|
||||||
|
BigDecimal rounded = cost.setScale(4, RoundingMode.HALF_UP);
|
||||||
|
StringBuilder sb = new StringBuilder("Kosten: $").append(rounded.toPlainString());
|
||||||
|
if (s.hasCacheTokensIgnored()) {
|
||||||
|
sb.append(" (ohne Cache-Anteil)");
|
||||||
|
}
|
||||||
|
if (s.hasMissingPriceSnapshot()) {
|
||||||
|
sb.append(" (unvollstaendig)");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildCacheOnlyLine(long count) {
|
||||||
|
return "ℹ " + count + " Cache-only Versuche (in Kosten nicht enthalten)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout.
|
||||||
|
*
|
||||||
|
* @return der Container-Knoten; nie null
|
||||||
|
*/
|
||||||
|
public HBox getNode() {
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Aggregations-Hilfe (thread-agnostisch, testbar ohne JavaFX)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt die Anzahl jedes {@link DocumentCompletionStatus} in der übergebenen
|
||||||
|
* Iterable. Einträge mit {@code resetPending=true} werden ignoriert, da sie
|
||||||
|
* keinen abgeschlossenen Verarbeitungszustand darstellen.
|
||||||
|
*
|
||||||
|
* <p>Diese Methode ist vollständig unabhängig von JavaFX und kann auf jedem
|
||||||
|
* Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param rows die Ergebniszeilen des Laufs; darf nicht null sein;
|
||||||
|
* null-Elemente werden übersprungen
|
||||||
|
* @return eine Map mit der Anzahl je Status; enthält alle anzuzeigenden
|
||||||
|
* Status (fehlende haben Wert 0); nie null
|
||||||
|
*/
|
||||||
|
public static Map<DocumentCompletionStatus, Integer> aggregateCounts(
|
||||||
|
Iterable<? extends GuiBatchRunResultRow> rows) {
|
||||||
|
Objects.requireNonNull(rows, "rows darf nicht null sein");
|
||||||
|
|
||||||
|
Map<DocumentCompletionStatus, Integer> counts = new EnumMap<>(DocumentCompletionStatus.class);
|
||||||
|
// Alle anzuzeigenden Status mit 0 vorbelegen
|
||||||
|
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
|
||||||
|
counts.put(status, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (GuiBatchRunResultRow row : rows) {
|
||||||
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Reset-Pending-Zeilen zählen nicht – sie haben noch keinen abgeschlossenen Status
|
||||||
|
if (row.resetPending()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DocumentCompletionStatus status = row.status();
|
||||||
|
// Nur anzuzeigende Status zählen (entspricht dem Ausschluss von
|
||||||
|
// Übergangszuständen wie READY_FOR_AI, PROPOSAL_READY, PROCESSING)
|
||||||
|
if (counts.containsKey(status)) {
|
||||||
|
counts.merge(status, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Interne Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den angezeigten Bannertext aus den Zählern.
|
||||||
|
* Liefert einen leeren String wenn alle Zähler null sind.
|
||||||
|
*
|
||||||
|
* @param counts die Zähler je Status; darf nicht null sein
|
||||||
|
* @return der fertige Bannertext oder ein leerer String
|
||||||
|
*/
|
||||||
|
static String buildBannerText(Map<DocumentCompletionStatus, Integer> counts) {
|
||||||
|
List<String> segments = new ArrayList<>();
|
||||||
|
for (DocumentCompletionStatus status : DISPLAYED_ORDER) {
|
||||||
|
int count = counts.getOrDefault(status, 0);
|
||||||
|
if (count > 0) {
|
||||||
|
String icon = ProcessingStatusPresentation.iconFor(status);
|
||||||
|
String category = ProcessingStatusPresentation.summaryCategoryFor(status);
|
||||||
|
segments.add(icon + " " + count + " " + category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String.join(SEGMENT_SEPARATOR, segments);
|
||||||
|
}
|
||||||
|
}
|
||||||
+495
@@ -0,0 +1,495 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.input.KeyCode;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailbereich-Komponente für die Bearbeitung des Zieldateinamens einer selektierten
|
||||||
|
* Ergebnis-Zeile.
|
||||||
|
* <p>
|
||||||
|
* Die Komponente kapselt Eingabefeld, Validierungsanzeige sowie die
|
||||||
|
* Schaltflächen „Dateiname übernehmen" und „Zurücksetzen auf KI-Vorschlag". Sie kennt
|
||||||
|
* drei Zustände gemäß fachlicher Spezifikation:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>KI-Vorschlag</b> – der ursprünglich generierte Name; unveränderlich pro Zeile.</li>
|
||||||
|
* <li><b>Letzter gespeicherter Name</b> – der zuletzt bestätigte Name; entspricht dem
|
||||||
|
* aktuellen Stand in Dateisystem und Persistenz.</li>
|
||||||
|
* <li><b>Aktuelle Eingabe</b> – der im Textfeld sichtbare Wert; kann vom letzten
|
||||||
|
* gespeicherten Namen abweichen (Dirty-State).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>
|
||||||
|
* Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
* Die tatsächliche Speicher-Operation ist in der Verantwortung des aufrufenden Tabs und
|
||||||
|
* läuft dort auf einem Hintergrund-Worker-Thread.
|
||||||
|
*/
|
||||||
|
public final class FileNameEditorPane {
|
||||||
|
|
||||||
|
/** Feste PDF-Erweiterung für Zieldateien. */
|
||||||
|
public static final String PDF_EXTENSION = ".pdf";
|
||||||
|
|
||||||
|
/** Windows-Maximal-Pfadlänge (MAX_PATH = 260 inkl. Null-Terminator = 259 nutzbar). */
|
||||||
|
public static final int MAX_WINDOWS_PATH_LENGTH = 259;
|
||||||
|
|
||||||
|
private static final Set<String> RESERVED_WINDOWS_NAMES = buildReservedWindowsNames();
|
||||||
|
private static final String FORBIDDEN_CHARS_REGEX = ".*[\\\\/:*?\"<>|].*";
|
||||||
|
|
||||||
|
private final VBox root = new VBox(4);
|
||||||
|
private final TextField textField = new TextField();
|
||||||
|
private final Label validationLabel = new Label();
|
||||||
|
private final Button saveButton = new Button("Dateiname übernehmen");
|
||||||
|
private final Button resetButton = new Button("Zurücksetzen auf KI-Vorschlag");
|
||||||
|
private final Label sectionTitle = new Label("Dateiname");
|
||||||
|
|
||||||
|
private Optional<String> aiProposal = Optional.empty();
|
||||||
|
private Optional<String> lastSavedName = Optional.empty();
|
||||||
|
private String targetFolderPath = "";
|
||||||
|
private boolean selectionEditable = false;
|
||||||
|
private boolean globalEnabled = true;
|
||||||
|
private boolean suppressValidation = false;
|
||||||
|
|
||||||
|
private Consumer<String> onSaveRequested = name -> { };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt die Komponente mit leerem und deaktiviertem Zustand.
|
||||||
|
*/
|
||||||
|
public FileNameEditorPane() {
|
||||||
|
sectionTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
|
textField.setId("filename-editor-text-field");
|
||||||
|
Tooltip textFieldTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_TEXTFELD);
|
||||||
|
textFieldTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
textField.setTooltip(textFieldTooltip);
|
||||||
|
HBox.setHgrow(textField, Priority.ALWAYS);
|
||||||
|
|
||||||
|
HBox inputRow = new HBox(4, textField);
|
||||||
|
inputRow.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
|
||||||
|
validationLabel.setId("filename-editor-validation-label");
|
||||||
|
validationLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #c62828;");
|
||||||
|
validationLabel.setVisible(false);
|
||||||
|
validationLabel.setManaged(false);
|
||||||
|
validationLabel.setWrapText(true);
|
||||||
|
|
||||||
|
saveButton.setId("filename-editor-save-button");
|
||||||
|
saveButton.setOnAction(e -> fireSaveRequest());
|
||||||
|
Tooltip saveTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_UEBERNEHMEN);
|
||||||
|
saveTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
saveButton.setTooltip(saveTooltip);
|
||||||
|
|
||||||
|
resetButton.setId("filename-editor-reset-button");
|
||||||
|
resetButton.setOnAction(e -> resetToAiProposal());
|
||||||
|
Tooltip resetTooltip = new Tooltip(GuiTooltipTexts.DATEINAME_ZURUECKSETZEN);
|
||||||
|
resetTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
resetButton.setTooltip(resetTooltip);
|
||||||
|
|
||||||
|
HBox buttonRow = new HBox(8, saveButton, resetButton);
|
||||||
|
buttonRow.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
buttonRow.setPadding(new Insets(4, 0, 0, 0));
|
||||||
|
|
||||||
|
root.getChildren().addAll(sectionTitle, inputRow, validationLabel, buttonRow);
|
||||||
|
root.setPadding(new Insets(0, 0, 4, 0));
|
||||||
|
|
||||||
|
// Live-Validierung auf jeden Tastendruck.
|
||||||
|
textField.textProperty().addListener((obs, oldText, newText) -> {
|
||||||
|
if (!suppressValidation) {
|
||||||
|
refreshUiState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter löst Speichern aus, Escape setzt auf lastSavedName zurück.
|
||||||
|
textField.setOnKeyPressed(event -> {
|
||||||
|
if (event.getCode() == KeyCode.ENTER) {
|
||||||
|
if (!saveButton.isDisabled()) {
|
||||||
|
fireSaveRequest();
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
} else if (event.getCode() == KeyCode.ESCAPE) {
|
||||||
|
discardChanges();
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
|
||||||
|
*
|
||||||
|
* @return das Root-Control der Komponente; nie null
|
||||||
|
*/
|
||||||
|
public Region getNode() {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registriert einen Callback, der ausgelöst wird, wenn der Benutzer „Dateiname übernehmen"
|
||||||
|
* anfordert. Parameter ist der gewünschte Basisname ohne {@code .pdf}-Erweiterung.
|
||||||
|
*
|
||||||
|
* @param callback Callback; darf nicht null sein (leerer Consumer als No-Op möglich)
|
||||||
|
*/
|
||||||
|
public void setOnSaveRequested(Consumer<String> callback) {
|
||||||
|
this.onSaveRequested = Objects.requireNonNull(callback, "callback must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert den Zustand für die neu selektierte Zeile.
|
||||||
|
* <p>
|
||||||
|
* Der KI-Vorschlag wird aus {@link GuiBatchRunResultRow#finalFileName()} abgeleitet,
|
||||||
|
* der letzte gespeicherte Name aus {@link GuiBatchRunResultRow#effectiveFileName()}.
|
||||||
|
* Editierbarkeitsregeln:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code resetPending} → nicht editierbar.</li>
|
||||||
|
* <li>{@code SUCCESS} und {@code SKIPPED_ALREADY_PROCESSED} → editierbar, sofern
|
||||||
|
* ein bisher gespeicherter Zieldateiname vorliegt (Umbenennen einer existierenden
|
||||||
|
* Zieldatei).</li>
|
||||||
|
* <li>{@code FAILED_RETRYABLE}, {@code FAILED_PERMANENT} und
|
||||||
|
* {@code SKIPPED_FINAL_FAILURE} → editierbar; das Eingabefeld erlaubt die
|
||||||
|
* Eingabe eines manuellen Zieldateinamens auch dann, wenn (noch) kein
|
||||||
|
* Vorschlag oder gespeicherter Name vorliegt (Kopieren der Quelldatei
|
||||||
|
* mit manuellem Namen).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param row die neu selektierte Zeile; {@code null} führt zu {@link #clearSelection()}
|
||||||
|
* @param targetFolderPath Zielordner-Pfad für die Pfadlängen-Validierung; darf
|
||||||
|
* {@code null} sein (wird als leer behandelt)
|
||||||
|
*/
|
||||||
|
public void loadSelection(GuiBatchRunResultRow row, String targetFolderPath) {
|
||||||
|
this.targetFolderPath = targetFolderPath == null ? "" : targetFolderPath;
|
||||||
|
if (row == null) {
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.aiProposal = stripPdfExtension(row.finalFileName());
|
||||||
|
this.lastSavedName = stripPdfExtension(row.effectiveFileName());
|
||||||
|
|
||||||
|
boolean editable;
|
||||||
|
if (row.resetPending()) {
|
||||||
|
editable = false;
|
||||||
|
} else if (requiresExistingTargetForRename(row.status())) {
|
||||||
|
// Umbenennen einer existierenden Zieldatei: nur sinnvoll, wenn ein
|
||||||
|
// gespeicherter Name vorliegt.
|
||||||
|
editable = lastSavedName.isPresent();
|
||||||
|
} else {
|
||||||
|
// Manuelle Kopie: das Feld ist auch ohne gespeicherten Namen editierbar.
|
||||||
|
editable = isRowEditable(row);
|
||||||
|
}
|
||||||
|
this.selectionEditable = editable;
|
||||||
|
|
||||||
|
suppressValidation = true;
|
||||||
|
try {
|
||||||
|
textField.setText(lastSavedName.orElse(""));
|
||||||
|
} finally {
|
||||||
|
suppressValidation = false;
|
||||||
|
}
|
||||||
|
refreshUiState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true}, wenn die Zeile einen Status hat, bei dem die Editierung
|
||||||
|
* eine bestehende Zieldatei umbenennt (im Gegensatz zur Kopie der Quelldatei).
|
||||||
|
*
|
||||||
|
* @param status der aggregierte Abschlussstatus der Zeile
|
||||||
|
* @return {@code true} für SUCCESS und SKIPPED_ALREADY_PROCESSED; sonst {@code false}
|
||||||
|
*/
|
||||||
|
private static boolean requiresExistingTargetForRename(DocumentCompletionStatus status) {
|
||||||
|
return status == DocumentCompletionStatus.SUCCESS
|
||||||
|
|| status == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leert die Komponente und deaktiviert die Eingabe. Wird aufgerufen wenn keine Zeile
|
||||||
|
* selektiert ist.
|
||||||
|
*/
|
||||||
|
public void clearSelection() {
|
||||||
|
this.aiProposal = Optional.empty();
|
||||||
|
this.lastSavedName = Optional.empty();
|
||||||
|
this.selectionEditable = false;
|
||||||
|
suppressValidation = true;
|
||||||
|
try {
|
||||||
|
textField.setText("");
|
||||||
|
} finally {
|
||||||
|
suppressValidation = false;
|
||||||
|
}
|
||||||
|
refreshUiState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Textfeldinhalt auf den zuletzt gespeicherten Namen zurück. Äquivalent zum
|
||||||
|
* Drücken der Escape-Taste im Textfeld.
|
||||||
|
*/
|
||||||
|
public void discardChanges() {
|
||||||
|
suppressValidation = true;
|
||||||
|
try {
|
||||||
|
textField.setText(lastSavedName.orElse(""));
|
||||||
|
} finally {
|
||||||
|
suppressValidation = false;
|
||||||
|
}
|
||||||
|
refreshUiState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Textfeldinhalt auf den KI-Vorschlag zurück. Es erfolgt <em>kein</em>
|
||||||
|
* Speichervorgang – der Benutzer kann anschließend über „Dateiname übernehmen"
|
||||||
|
* bestätigen.
|
||||||
|
*/
|
||||||
|
public void resetToAiProposal() {
|
||||||
|
if (aiProposal.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suppressValidation = true;
|
||||||
|
try {
|
||||||
|
textField.setText(aiProposal.get());
|
||||||
|
} finally {
|
||||||
|
suppressValidation = false;
|
||||||
|
}
|
||||||
|
refreshUiState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktiviert oder deaktiviert die gesamte Komponente. Während eines laufenden Batch-Laufs
|
||||||
|
* soll die Komponente deaktiviert sein.
|
||||||
|
*
|
||||||
|
* @param enabled {@code true} wenn Bedienung erlaubt ist
|
||||||
|
*/
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.globalEnabled = enabled;
|
||||||
|
refreshUiState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true} wenn die aktuelle Texteingabe vom letzten gespeicherten Namen
|
||||||
|
* abweicht.
|
||||||
|
*
|
||||||
|
* @return ob ungespeicherte Änderungen im Textfeld vorliegen
|
||||||
|
*/
|
||||||
|
public boolean isDirty() {
|
||||||
|
if (!selectionEditable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String current = textField.getText() == null ? "" : textField.getText();
|
||||||
|
String saved = lastSavedName.orElse("");
|
||||||
|
return !current.equals(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Dirty-State zurück, ohne das Textfeld neu zu laden. Wird aufgerufen,
|
||||||
|
* nachdem eine Umbenennung erfolgreich abgeschlossen wurde, damit ein anschließendes
|
||||||
|
* Ersetzen der Tabellenzeile keinen Verwerfen-Dialog auslöst. Der angezeigte Text
|
||||||
|
* im Textfeld bleibt unverändert; {@code lastSavedName} wird auf den aktuellen
|
||||||
|
* Textfeldinhalt gesetzt.
|
||||||
|
*/
|
||||||
|
public void clearDirtyState() {
|
||||||
|
String current = textField.getText() == null ? "" : textField.getText();
|
||||||
|
this.lastSavedName = current.isBlank() ? Optional.empty() : Optional.of(current);
|
||||||
|
refreshUiState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true} wenn für die aktuelle Zeile ein KI-Vorschlag vorliegt.
|
||||||
|
*
|
||||||
|
* @return ob ein KI-Vorschlag existiert
|
||||||
|
*/
|
||||||
|
public boolean hasAiProposal() {
|
||||||
|
return aiProposal.isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true} wenn für die aktuelle Zeile ein zuletzt gespeicherter Name
|
||||||
|
* existiert.
|
||||||
|
*
|
||||||
|
* @return ob ein letzter gespeicherter Name existiert
|
||||||
|
*/
|
||||||
|
public boolean hasLastSaved() {
|
||||||
|
return lastSavedName.isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert intern den letzten gespeicherten Namen. Typisch nach erfolgreichem
|
||||||
|
* Speichervorgang im Tab (ohne erneut {@link #loadSelection(GuiBatchRunResultRow, String)}
|
||||||
|
* aufzurufen).
|
||||||
|
*
|
||||||
|
* @param newLastSavedName neuer letzter gespeicherter Name ohne {@code .pdf}; darf
|
||||||
|
* {@code null} sein
|
||||||
|
*/
|
||||||
|
public void updateLastSavedName(String newLastSavedName) {
|
||||||
|
this.lastSavedName = newLastSavedName == null || newLastSavedName.isBlank()
|
||||||
|
? Optional.empty()
|
||||||
|
: Optional.of(newLastSavedName);
|
||||||
|
suppressValidation = true;
|
||||||
|
try {
|
||||||
|
textField.setText(lastSavedName.orElse(""));
|
||||||
|
} finally {
|
||||||
|
suppressValidation = false;
|
||||||
|
}
|
||||||
|
refreshUiState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test-Accessoren ------------------------------------------------------
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
TextField textField() {
|
||||||
|
return textField;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Label validationLabel() {
|
||||||
|
return validationLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Button saveButton() {
|
||||||
|
return saveButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Button resetButton() {
|
||||||
|
return resetButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interne Helfer -------------------------------------------------------
|
||||||
|
|
||||||
|
private void fireSaveRequest() {
|
||||||
|
if (saveButton.isDisabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String current = textField.getText() == null ? "" : textField.getText();
|
||||||
|
onSaveRequested.accept(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshUiState() {
|
||||||
|
boolean enabled = selectionEditable && globalEnabled;
|
||||||
|
textField.setDisable(!enabled);
|
||||||
|
// Button „Zurücksetzen auf KI-Vorschlag" ist nur aktiv, wenn Eingabe möglich
|
||||||
|
// und ein KI-Vorschlag vorliegt.
|
||||||
|
resetButton.setDisable(aiProposal.isEmpty() || !enabled);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
// Validierung und Speichern-Button unterdrücken, Rahmen neutral.
|
||||||
|
validationLabel.setVisible(false);
|
||||||
|
validationLabel.setManaged(false);
|
||||||
|
textField.setStyle("");
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String current = textField.getText() == null ? "" : textField.getText();
|
||||||
|
Optional<String> error = validate(current);
|
||||||
|
|
||||||
|
if (error.isPresent()) {
|
||||||
|
validationLabel.setText(error.get());
|
||||||
|
validationLabel.setVisible(true);
|
||||||
|
validationLabel.setManaged(true);
|
||||||
|
textField.setStyle("-fx-border-color: #c62828; -fx-border-width: 1.5;");
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
} else {
|
||||||
|
validationLabel.setVisible(false);
|
||||||
|
validationLabel.setManaged(false);
|
||||||
|
if (isDirty()) {
|
||||||
|
// Dirty-Markierung: orangefarbener Rand.
|
||||||
|
textField.setStyle("-fx-border-color: #e65100; -fx-border-width: 1.5;");
|
||||||
|
saveButton.setDisable(false);
|
||||||
|
} else {
|
||||||
|
textField.setStyle("");
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt die vollständige Dateinamen-Validierung aus und liefert gegebenenfalls den
|
||||||
|
* fachlichen Fehlertext. Paket-privat für Unit-Tests.
|
||||||
|
*
|
||||||
|
* @param input Eingabe aus dem Textfeld (ohne {@code .pdf})
|
||||||
|
* @return der Fehlertext oder {@link Optional#empty()} wenn gültig
|
||||||
|
*/
|
||||||
|
Optional<String> validate(String input) {
|
||||||
|
if (input == null || input.isBlank()) {
|
||||||
|
return Optional.of("Dateiname darf nicht leer sein");
|
||||||
|
}
|
||||||
|
if (!input.equals(input.strip())) {
|
||||||
|
return Optional.of("Leerzeichen am Anfang oder Ende nicht erlaubt");
|
||||||
|
}
|
||||||
|
if (input.matches(FORBIDDEN_CHARS_REGEX)) {
|
||||||
|
return Optional.of("Unerlaubtes Zeichen (nicht erlaubt: \\ / : * ? \" < > |)");
|
||||||
|
}
|
||||||
|
if (RESERVED_WINDOWS_NAMES.contains(input.toUpperCase(java.util.Locale.ROOT))) {
|
||||||
|
return Optional.of("Reservierter Systemname");
|
||||||
|
}
|
||||||
|
if (input.endsWith(".")) {
|
||||||
|
return Optional.of("Dateiname darf nicht auf einen Punkt enden");
|
||||||
|
}
|
||||||
|
int totalLength = pathLengthEstimate(input);
|
||||||
|
if (totalLength > MAX_WINDOWS_PATH_LENGTH) {
|
||||||
|
return Optional.of("Dateipfad zu lang (Windows-Limit " + MAX_WINDOWS_PATH_LENGTH
|
||||||
|
+ " Zeichen, aktuell " + totalLength + ")");
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int pathLengthEstimate(String baseName) {
|
||||||
|
String folder = targetFolderPath == null ? "" : targetFolderPath;
|
||||||
|
int folderLength = folder.length();
|
||||||
|
int separatorLength = folderLength == 0 ? 0 : 1;
|
||||||
|
return folderLength + separatorLength + baseName.length() + PDF_EXTENSION.length();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true}, wenn die Zeile fachlich für eine manuelle Dateinamens-Aktion
|
||||||
|
* editierbar ist.
|
||||||
|
* <p>
|
||||||
|
* Editierbar sind alle nicht-resetpending-Zeilen unabhängig davon, ob die Aktion
|
||||||
|
* eine Zieldatei umbenennt (SUCCESS, SKIPPED_ALREADY_PROCESSED) oder die Quelldatei
|
||||||
|
* kopiert (FAILED_*, SKIPPED_FINAL_FAILURE). Die genaue Aktion wird vom Tab anhand
|
||||||
|
* des Status entschieden.
|
||||||
|
*
|
||||||
|
* @param row die Zeile, deren Editierbarkeit geprüft werden soll
|
||||||
|
* @return {@code true} wenn die Zeile editierbar ist; sonst {@code false}
|
||||||
|
*/
|
||||||
|
private static boolean isRowEditable(GuiBatchRunResultRow row) {
|
||||||
|
return !row.resetPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<String> stripPdfExtension(Optional<String> fileNameWithExtension) {
|
||||||
|
if (fileNameWithExtension.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String raw = fileNameWithExtension.get();
|
||||||
|
if (raw.toLowerCase(java.util.Locale.ROOT).endsWith(PDF_EXTENSION)) {
|
||||||
|
return Optional.of(raw.substring(0, raw.length() - PDF_EXTENSION.length()));
|
||||||
|
}
|
||||||
|
return Optional.of(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> buildReservedWindowsNames() {
|
||||||
|
Set<String> reserved = new HashSet<>();
|
||||||
|
reserved.add("CON");
|
||||||
|
reserved.add("PRN");
|
||||||
|
reserved.add("AUX");
|
||||||
|
reserved.add("NUL");
|
||||||
|
for (int i = 1; i <= 9; i++) {
|
||||||
|
reserved.add("COM" + i);
|
||||||
|
reserved.add("LPT" + i);
|
||||||
|
}
|
||||||
|
return Set.copyOf(reserved);
|
||||||
|
}
|
||||||
|
}
|
||||||
+769
@@ -0,0 +1,769 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates a single batch run (regular or targeted mini-run) triggered from the
|
||||||
|
* JavaFX GUI, and optional reset-only operations on selected document fingerprints.
|
||||||
|
* <p>
|
||||||
|
* The coordinator owns the background worker thread that executes the run, maintains the
|
||||||
|
* cancellation flag, and translates the
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
|
||||||
|
* callbacks into a GUI-friendly event stream on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>The batch run and reset operations execute on a daemon worker thread created by
|
||||||
|
* {@link #threadFactory}. No JavaFX code touches this thread.</li>
|
||||||
|
* <li>Every GUI callback ({@link Listener}) is invoked on the JavaFX Application Thread
|
||||||
|
* via {@link Platform#runLater(Runnable)}, so listeners may freely mutate
|
||||||
|
* {@code Control}s without taking any further precautions.</li>
|
||||||
|
* <li>{@link #requestCancellation()} sets a volatile flag that the use case polls
|
||||||
|
* between candidates (soft-stop). It never interrupts the worker thread; the
|
||||||
|
* currently-processed candidate always completes in full.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Lifecycle</h2>
|
||||||
|
* <ol>
|
||||||
|
* <li>Construct with a regular launcher, a mini-run launcher, a reset port, a thread
|
||||||
|
* factory and a listener.</li>
|
||||||
|
* <li>Call {@link #start(Path)} to begin a regular run, or
|
||||||
|
* {@link #startMiniRun(Path, Set)} for a targeted mini-run, or
|
||||||
|
* {@link #startReset(Path, Set)} for a status-reset-only operation.</li>
|
||||||
|
* <li>Optionally call {@link #requestCancellation()} to trigger soft-stop for runs.</li>
|
||||||
|
* <li>Wait for {@link Listener#onRunEnded(RunSummary, GuiBatchRunLaunchOutcome)} or
|
||||||
|
* {@link Listener#onResetCompleted(ResetDocumentStatusResult)} on the FX thread.</li>
|
||||||
|
* <li>Start a new operation only after the previous one has ended.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public final class GuiBatchRunCoordinator {
|
||||||
|
private static final String CONFIG_FILE_NOT_NULL = "configFilePath must not be null";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
|
||||||
|
private static final String WORKER_THREAD_NAME = "gui-batch-run";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener interface invoked on the JavaFX Application Thread during a run or reset.
|
||||||
|
*/
|
||||||
|
public interface Listener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once, after the batch use case has scanned the source folder and knows
|
||||||
|
* the total candidate count.
|
||||||
|
*
|
||||||
|
* @param runId the identifier of the run; never {@code null}
|
||||||
|
* @param totalCandidates the number of candidates detected in the source folder;
|
||||||
|
* never negative
|
||||||
|
*/
|
||||||
|
void onRunStarted(RunId runId, int totalCandidates);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once per candidate whose processing reached a terminal resolution.
|
||||||
|
*
|
||||||
|
* @param row the row describing the candidate result; never {@code null}
|
||||||
|
*/
|
||||||
|
void onDocumentCompleted(GuiBatchRunResultRow row);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once after the run has fully terminated on the worker thread.
|
||||||
|
*
|
||||||
|
* @param summary the final outcome counts; never {@code null}
|
||||||
|
* @param outcome a description of how the run terminated; never {@code null}
|
||||||
|
*/
|
||||||
|
void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once after a reset-only operation has completed on the worker thread.
|
||||||
|
* <p>
|
||||||
|
* The default implementation does nothing so existing {@link Listener}
|
||||||
|
* implementations need not override this method until they need reset
|
||||||
|
* notifications.
|
||||||
|
*
|
||||||
|
* @param result the full outcome of the reset operation; never {@code null}
|
||||||
|
*/
|
||||||
|
default void onResetCompleted(ResetDocumentStatusResult result) {
|
||||||
|
// no-op default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final GuiBatchRunLauncher launcher;
|
||||||
|
private final GuiMiniRunLauncher miniRunLauncher;
|
||||||
|
private final GuiResetDocumentStatusPort resetPort;
|
||||||
|
private final Function<Runnable, Thread> threadFactory;
|
||||||
|
private final Consumer<Runnable> fxDispatcher;
|
||||||
|
private final Listener listener;
|
||||||
|
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
||||||
|
private final Optional<ConfigurationFileLockPort> configurationFileLockPort;
|
||||||
|
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
|
||||||
|
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the coordinator with the default worker-thread factory and the default
|
||||||
|
* JavaFX Application Thread dispatcher.
|
||||||
|
* <p>
|
||||||
|
* Mini-run and reset capabilities are unavailable; all such requests will return
|
||||||
|
* {@code false}.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap used to execute the batch; must not be null
|
||||||
|
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher, Listener listener) {
|
||||||
|
this(launcher,
|
||||||
|
rejectingMiniRunLauncher(),
|
||||||
|
rejectingResetPort(),
|
||||||
|
defaultThreadFactory(),
|
||||||
|
defaultFxDispatcher(),
|
||||||
|
listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the coordinator with all ports and the default worker-thread factory and
|
||||||
|
* JavaFX Application Thread dispatcher.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||||
|
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||||
|
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||||
|
* not be null
|
||||||
|
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetPort,
|
||||||
|
Listener listener) {
|
||||||
|
this(launcher, miniRunLauncher, resetPort,
|
||||||
|
defaultThreadFactory(), defaultFxDispatcher(), listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the coordinator with all ports and the historical file name port, using the
|
||||||
|
* default worker-thread factory and JavaFX Application Thread dispatcher.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||||
|
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||||
|
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||||
|
* not be null
|
||||||
|
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||||
|
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
|
||||||
|
* skipped documents; must not be null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetPort,
|
||||||
|
Listener listener,
|
||||||
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
||||||
|
this(launcher, miniRunLauncher, resetPort,
|
||||||
|
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the coordinator with all ports and the configuration file lock port, using
|
||||||
|
* the default worker-thread factory and JavaFX Application Thread dispatcher.
|
||||||
|
* <p>
|
||||||
|
* This constructor is intended for production wiring in {@code GuiBatchRunTab} where
|
||||||
|
* the lock port is supplied by Bootstrap.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||||
|
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||||
|
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||||
|
* not be null
|
||||||
|
* @param listener GUI listener invoked on the FX thread; must not be null
|
||||||
|
* @param historicalDocumentContextPort port for resolving historical context; must not be null
|
||||||
|
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
|
||||||
|
* acquired before each run; {@code null} is treated as empty
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetPort,
|
||||||
|
Listener listener,
|
||||||
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||||
|
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
|
||||||
|
this(launcher, miniRunLauncher, resetPort,
|
||||||
|
defaultThreadFactory(), defaultFxDispatcher(), listener,
|
||||||
|
historicalDocumentContextPort, configurationFileLockPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the coordinator with custom hooks for the worker-thread factory and the
|
||||||
|
* UI-thread dispatcher.
|
||||||
|
* <p>
|
||||||
|
* Tests use this constructor to execute batches synchronously or to verify which
|
||||||
|
* thread UI callbacks run on, without depending on an actual JavaFX runtime being
|
||||||
|
* initialised.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||||
|
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||||
|
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||||
|
* not be null
|
||||||
|
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||||
|
* be null
|
||||||
|
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
|
||||||
|
* Thread; must not be null
|
||||||
|
* @param listener GUI listener; must not be null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetPort,
|
||||||
|
Function<Runnable, Thread> threadFactory,
|
||||||
|
Consumer<Runnable> fxDispatcher,
|
||||||
|
Listener listener) {
|
||||||
|
this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
|
||||||
|
noOpHistoricalDocumentContextPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the coordinator with all ports, custom thread factory, FX dispatcher,
|
||||||
|
* historical file name port, and an optional configuration file lock port.
|
||||||
|
* <p>
|
||||||
|
* This is the canonical constructor. All other constructors delegate here.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||||
|
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||||
|
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||||
|
* not be null
|
||||||
|
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||||
|
* be null
|
||||||
|
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
|
||||||
|
* Thread; must not be null
|
||||||
|
* @param listener GUI listener; must not be null
|
||||||
|
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
|
||||||
|
* skipped documents; must not be null
|
||||||
|
* @param configurationFileLockPort optional OS-lock on the configuration file; when present,
|
||||||
|
* acquired before each run and released in a finally block;
|
||||||
|
* {@code null} is treated as empty
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetPort,
|
||||||
|
Function<Runnable, Thread> threadFactory,
|
||||||
|
Consumer<Runnable> fxDispatcher,
|
||||||
|
Listener listener,
|
||||||
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort,
|
||||||
|
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
|
||||||
|
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
|
||||||
|
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
|
||||||
|
this.resetPort = Objects.requireNonNull(resetPort, "resetPort must not be null");
|
||||||
|
this.threadFactory = Objects.requireNonNull(threadFactory, "threadFactory must not be null");
|
||||||
|
this.fxDispatcher = Objects.requireNonNull(fxDispatcher, "fxDispatcher must not be null");
|
||||||
|
this.listener = Objects.requireNonNull(listener, "listener must not be null");
|
||||||
|
this.historicalDocumentContextPort = Objects.requireNonNull(
|
||||||
|
historicalDocumentContextPort, "historicalDocumentContextPort must not be null");
|
||||||
|
this.configurationFileLockPort =
|
||||||
|
Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible constructor that omits the configuration file lock port.
|
||||||
|
* <p>
|
||||||
|
* Preserves existing callers that were written before the lock port was added.
|
||||||
|
* Delegates to the canonical constructor with {@code configurationFileLockPort} empty.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap for regular batch runs; must not be null
|
||||||
|
* @param miniRunLauncher bridge to Bootstrap for targeted mini-runs; must not be null
|
||||||
|
* @param resetPort bridge to Bootstrap for status-reset-only operations; must
|
||||||
|
* not be null
|
||||||
|
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||||
|
* be null
|
||||||
|
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
|
||||||
|
* Thread; must not be null
|
||||||
|
* @param listener GUI listener; must not be null
|
||||||
|
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
|
||||||
|
* skipped documents; must not be null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
|
GuiResetDocumentStatusPort resetPort,
|
||||||
|
Function<Runnable, Thread> threadFactory,
|
||||||
|
Consumer<Runnable> fxDispatcher,
|
||||||
|
Listener listener,
|
||||||
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
||||||
|
this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
|
||||||
|
historicalDocumentContextPort, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy constructor retained for backward compatibility with tests that do not
|
||||||
|
* require mini-run or reset capabilities.
|
||||||
|
*
|
||||||
|
* @param launcher bridge to Bootstrap; must not be null
|
||||||
|
* @param threadFactory factory returning a ready-to-start worker thread; must not
|
||||||
|
* be null
|
||||||
|
* @param fxDispatcher dispatcher that schedules a runnable on the JavaFX Application
|
||||||
|
* Thread; must not be null
|
||||||
|
* @param listener GUI listener; must not be null
|
||||||
|
*/
|
||||||
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
|
Function<Runnable, Thread> threadFactory,
|
||||||
|
Consumer<Runnable> fxDispatcher,
|
||||||
|
Listener listener) {
|
||||||
|
this(launcher,
|
||||||
|
rejectingMiniRunLauncher(),
|
||||||
|
rejectingResetPort(),
|
||||||
|
threadFactory,
|
||||||
|
fxDispatcher,
|
||||||
|
listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a run or reset is currently active.
|
||||||
|
*
|
||||||
|
* @return {@code true} while a worker thread is executing
|
||||||
|
*/
|
||||||
|
public boolean isRunning() {
|
||||||
|
Thread worker = activeWorker.get();
|
||||||
|
return worker != null && worker.isAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a new regular run for the supplied configuration file.
|
||||||
|
* <p>
|
||||||
|
* Immediately returns once the worker thread has been started. All further progress
|
||||||
|
* is communicated through the configured {@link Listener} on the JavaFX Application
|
||||||
|
* Thread. An attempt to start a new run while another is still active is rejected
|
||||||
|
* with {@code false} and leaves the currently running batch untouched.
|
||||||
|
*
|
||||||
|
* @param configFilePath the configuration file the run shall read from; must not be
|
||||||
|
* {@code null}
|
||||||
|
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||||
|
* was already in progress
|
||||||
|
* @throws NullPointerException if {@code configFilePath} is {@code null}
|
||||||
|
*/
|
||||||
|
public boolean start(Path configFilePath) {
|
||||||
|
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||||
|
if (isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
cancellationRequested.set(false);
|
||||||
|
Runnable task = () -> executeRun(configFilePath);
|
||||||
|
return startWorker(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a targeted mini-run for the supplied fingerprint filter.
|
||||||
|
* <p>
|
||||||
|
* The worker thread first delegates to the {@link GuiMiniRunLauncher} which applies
|
||||||
|
* the full processing pipeline to only the specified documents. Progress callbacks
|
||||||
|
* are forwarded to the {@link Listener} on the JavaFX Application Thread in the same
|
||||||
|
* way as for a regular run.
|
||||||
|
*
|
||||||
|
* @param configFilePath the configuration file; must not be {@code null}
|
||||||
|
* @param fingerprintFilter the set of document fingerprints to process; must not be
|
||||||
|
* {@code null}
|
||||||
|
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||||
|
* was already in progress
|
||||||
|
* @throws NullPointerException if any argument is {@code null}
|
||||||
|
*/
|
||||||
|
public boolean startMiniRun(Path configFilePath,
|
||||||
|
Set<DocumentFingerprint> fingerprintFilter) {
|
||||||
|
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||||
|
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
||||||
|
if (isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
cancellationRequested.set(false);
|
||||||
|
Runnable task = () -> executeMiniRun(configFilePath, fingerprintFilter);
|
||||||
|
return startWorker(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a reprocessing operation: resets the database status of the specified
|
||||||
|
* fingerprints and immediately launches a targeted mini-run for them.
|
||||||
|
* <p>
|
||||||
|
* This method is the preferred entry point for "Erneut verarbeiten" (reprocess)
|
||||||
|
* actions in the GUI. It ensures that documents marked as FAILED_FINAL or otherwise
|
||||||
|
* ineligible for processing are reset before the mini-run begins, so they are
|
||||||
|
* reprocessed rather than skipped.
|
||||||
|
* <p>
|
||||||
|
* The reset executes synchronously on the caller's thread before the worker thread
|
||||||
|
* is started. This guarantees that the mini-run sees the documents in a
|
||||||
|
* reprocessable state.
|
||||||
|
*
|
||||||
|
* @param configFilePath the configuration file; must not be {@code null}
|
||||||
|
* @param fingerprintFilter the set of document fingerprints to reset and process;
|
||||||
|
* must not be {@code null}
|
||||||
|
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||||
|
* was already in progress or when the reset failed for all fingerprints
|
||||||
|
* @throws NullPointerException if any argument is {@code null}
|
||||||
|
*/
|
||||||
|
public boolean startReprocessing(Path configFilePath,
|
||||||
|
Set<DocumentFingerprint> fingerprintFilter) {
|
||||||
|
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||||
|
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
||||||
|
if (isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Reset the database status synchronously before starting the mini-run.
|
||||||
|
// This ensures that documents are not skipped due to FAILED_FINAL or other
|
||||||
|
// terminal states.
|
||||||
|
LOG.info("GUI-Erneut-Verarbeiten: Starte Status-Reset für {} Dokument(e), Konfiguration={}.",
|
||||||
|
fingerprintFilter.size(), configFilePath);
|
||||||
|
ResetDocumentStatusResult resetResult = resetPort.reset(configFilePath, fingerprintFilter);
|
||||||
|
LOG.info("GUI-Erneut-Verarbeiten: Status-Reset abgeschlossen – {} erfolgreich, {} fehlgeschlagen.",
|
||||||
|
resetResult.successCount(), resetResult.failureCount());
|
||||||
|
if (resetResult.successCount() == 0) {
|
||||||
|
LOG.warn("GUI-Reprocessing: Reset für alle {} Dokumente fehlgeschlagen; "
|
||||||
|
+ "Mini-Lauf wird nicht gestartet.", fingerprintFilter.size());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LOG.info("GUI-Reprocessing: {} von {} Dokumenten erfolgreich zurückgesetzt.",
|
||||||
|
resetResult.successCount(), resetResult.requestedCount());
|
||||||
|
// Now start the mini-run with the reset fingerprints.
|
||||||
|
return startMiniRun(configFilePath, fingerprintFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a reset-only operation for the supplied fingerprint set.
|
||||||
|
* <p>
|
||||||
|
* The worker thread calls the {@link GuiResetDocumentStatusPort} to delete all
|
||||||
|
* persistence data for the specified fingerprints. No reprocessing run is triggered.
|
||||||
|
* On completion the {@link Listener#onResetCompleted(ResetDocumentStatusResult)} callback
|
||||||
|
* is invoked on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param configFilePath the configuration file that identifies the database; must not
|
||||||
|
* be {@code null}
|
||||||
|
* @param fingerprints the set of document fingerprints to reset; must not be
|
||||||
|
* {@code null}
|
||||||
|
* @return {@code true} when a new worker thread was started, {@code false} when a run
|
||||||
|
* was already in progress
|
||||||
|
* @throws NullPointerException if any argument is {@code null}
|
||||||
|
*/
|
||||||
|
public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
||||||
|
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||||
|
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
|
||||||
|
if (isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Reset does not support cancellation; set the flag to false so the
|
||||||
|
// running state is consistent with the pattern used by run operations.
|
||||||
|
cancellationRequested.set(false);
|
||||||
|
Runnable task = () -> executeReset(configFilePath, fingerprints);
|
||||||
|
return startWorker(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests soft-stop cancellation of the currently running batch or mini-run.
|
||||||
|
* <p>
|
||||||
|
* The flag is honoured between candidates — the candidate that is currently being
|
||||||
|
* processed is always completed in full and persisted before the run ends. Calling
|
||||||
|
* this method when no run is active has no effect. Reset operations ignore this flag.
|
||||||
|
*/
|
||||||
|
public void requestCancellation() {
|
||||||
|
if (isRunning()) {
|
||||||
|
cancellationRequested.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether cancellation has been requested for the current (or last) run.
|
||||||
|
*
|
||||||
|
* @return {@code true} when a cancellation request is pending or was pending when
|
||||||
|
* the last run ended; {@code false} before the first run
|
||||||
|
*/
|
||||||
|
public boolean isCancellationRequested() {
|
||||||
|
return cancellationRequested.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Worker helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private boolean startWorker(Runnable task) {
|
||||||
|
Thread worker = threadFactory.apply(task);
|
||||||
|
Objects.requireNonNull(worker, "threadFactory must not return null");
|
||||||
|
activeWorker.set(worker);
|
||||||
|
worker.start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeRun(Path configFilePath) {
|
||||||
|
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
|
||||||
|
configFilePath);
|
||||||
|
observerSummary.set(null);
|
||||||
|
|
||||||
|
if (configurationFileLockPort.isPresent()) {
|
||||||
|
try {
|
||||||
|
configurationFileLockPort.get().acquireLock();
|
||||||
|
} catch (ConfigurationFileLockException e) {
|
||||||
|
LOG.warn("GUI-Verarbeitungslauf: Konfigurationsdatei gesperrt – Lauf abgebrochen: {}",
|
||||||
|
e.getMessage());
|
||||||
|
fxDispatcher.accept(() -> showLockErrorAlert());
|
||||||
|
finishRun(GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Konfigurationsdatei gesperrt – Lauf wurde abgebrochen."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
|
||||||
|
BatchRunCancellationToken token = cancellationRequested::get;
|
||||||
|
GuiBatchRunLaunchOutcome outcome;
|
||||||
|
try {
|
||||||
|
outcome = launcher.launch(configFilePath, observer, token);
|
||||||
|
if (outcome == null) {
|
||||||
|
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Launcher hat kein Ergebnis geliefert.");
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Unerwarteter technischer Fehler: "
|
||||||
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
|
}
|
||||||
|
finishRun(outcome);
|
||||||
|
} finally {
|
||||||
|
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeMiniRun(Path configFilePath, Set<DocumentFingerprint> fingerprintFilter) {
|
||||||
|
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
|
||||||
|
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
|
||||||
|
observerSummary.set(null);
|
||||||
|
|
||||||
|
if (configurationFileLockPort.isPresent()) {
|
||||||
|
try {
|
||||||
|
configurationFileLockPort.get().acquireLock();
|
||||||
|
} catch (ConfigurationFileLockException e) {
|
||||||
|
LOG.warn("GUI-Mini-Verarbeitungslauf: Konfigurationsdatei gesperrt – Lauf abgebrochen: {}",
|
||||||
|
e.getMessage());
|
||||||
|
fxDispatcher.accept(() -> showLockErrorAlert());
|
||||||
|
finishRun(GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Konfigurationsdatei gesperrt – Mini-Lauf wurde abgebrochen."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
|
||||||
|
BatchRunCancellationToken token = cancellationRequested::get;
|
||||||
|
GuiBatchRunLaunchOutcome outcome;
|
||||||
|
try {
|
||||||
|
outcome = miniRunLauncher.launch(configFilePath, fingerprintFilter, observer, token);
|
||||||
|
if (outcome == null) {
|
||||||
|
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Mini-Run-Launcher hat kein Ergebnis geliefert.");
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Mini-Verarbeitungslauf: Unerwarteter Fehler im Worker-Thread: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
outcome = GuiBatchRunLaunchOutcome.failedAfterStart(
|
||||||
|
"Unerwarteter technischer Fehler im Mini-Lauf: "
|
||||||
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
|
}
|
||||||
|
finishRun(outcome);
|
||||||
|
} finally {
|
||||||
|
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
||||||
|
LOG.info("GUI-Status-Reset: Worker-Thread gestartet für {} Dokument(e), "
|
||||||
|
+ "Konfiguration {}.", fingerprints.size(), configFilePath);
|
||||||
|
ResetDocumentStatusResult result;
|
||||||
|
try {
|
||||||
|
result = resetPort.reset(configFilePath, fingerprints);
|
||||||
|
if (result == null) {
|
||||||
|
result = new ResetDocumentStatusResult(fingerprints.size(),
|
||||||
|
Set.of(), allFailureMap(fingerprints,
|
||||||
|
"Reset-Port hat kein Ergebnis geliefert."));
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Status-Reset: Unerwarteter Fehler im Worker-Thread: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
String msg = "Unerwarteter technischer Fehler beim Status-Reset: "
|
||||||
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||||
|
result = new ResetDocumentStatusResult(fingerprints.size(),
|
||||||
|
Set.of(), allFailureMap(fingerprints, msg));
|
||||||
|
}
|
||||||
|
ResetDocumentStatusResult finalResult = result;
|
||||||
|
activeWorker.set(null);
|
||||||
|
fxDispatcher.accept(() -> listener.onResetCompleted(finalResult));
|
||||||
|
LOG.info("GUI-Status-Reset: Worker-Thread beendet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishRun(GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
RunSummary summary = observerSummary.get();
|
||||||
|
if (summary == null) {
|
||||||
|
summary = new RunSummary(0, 0, 0);
|
||||||
|
}
|
||||||
|
GuiBatchRunLaunchOutcome finalOutcome = outcome;
|
||||||
|
RunSummary finalSummary = summary;
|
||||||
|
activeWorker.set(null);
|
||||||
|
fxDispatcher.accept(() -> listener.onRunEnded(finalSummary, finalOutcome));
|
||||||
|
LOG.info("GUI-Verarbeitungslauf: Worker-Thread beendet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.util.Map<DocumentFingerprint, String> allFailureMap(
|
||||||
|
Set<DocumentFingerprint> fingerprints, String message) {
|
||||||
|
java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
|
||||||
|
for (DocumentFingerprint fp : fingerprints) {
|
||||||
|
map.put(fp, message);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the final summary supplied by the application layer. Written on the
|
||||||
|
* worker thread; read only after the run has ended.
|
||||||
|
*/
|
||||||
|
private final AtomicReference<RunSummary> observerSummary = new AtomicReference<>();
|
||||||
|
|
||||||
|
private BatchRunProgressObserver buildDispatchingObserver(Path configFilePath) {
|
||||||
|
return new BatchRunProgressObserver() {
|
||||||
|
@Override
|
||||||
|
public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
|
fxDispatcher.accept(() -> listener.onRunStarted(runId, totalCandidates));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDocumentCompleted(DocumentCompletionEvent event) {
|
||||||
|
GuiBatchRunResultRow row = toRow(event, configFilePath);
|
||||||
|
fxDispatcher.accept(() -> listener.onDocumentCompleted(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRunEnded(RunSummary summary) {
|
||||||
|
observerSummary.set(summary);
|
||||||
|
// Kein FX-Dispatch hier: der Worker-Thread ruft onRunEnded über finishRun()
|
||||||
|
// auf, nachdem der Launcher zurückgekehrt ist.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt ein {@link DocumentCompletionEvent} in eine {@link GuiBatchRunResultRow} um.
|
||||||
|
* <p>
|
||||||
|
* Für übersprungene Dokumente ({@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}
|
||||||
|
* und {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}) wird der historische
|
||||||
|
* Verarbeitungskontext über den {@link GuiHistoricalDocumentContextPort} nachgeladen.
|
||||||
|
* Für SKIPPED_ALREADY_PROCESSED wird der letzte Zieldateiname aus dem Kontext als
|
||||||
|
* {@code finalName} übernommen. Schlägt die Abfrage fehl, bleibt der Kontext leer.
|
||||||
|
* Die Methode läuft auf dem Worker-Thread.
|
||||||
|
*
|
||||||
|
* @param event das abgeschlossene Kandidatenereignis; darf nicht {@code null} sein
|
||||||
|
* @param configFilePath Pfad zur aktiven Konfigurationsdatei; darf nicht {@code null} sein
|
||||||
|
* @return eine neue {@link GuiBatchRunResultRow}; nie {@code null}
|
||||||
|
*/
|
||||||
|
private GuiBatchRunResultRow toRow(DocumentCompletionEvent event, Path configFilePath) {
|
||||||
|
Optional<String> finalName = event.finalFileName() == null
|
||||||
|
? Optional.empty() : Optional.of(event.finalFileName());
|
||||||
|
Optional<LocalDate> date = event.resolvedDate() == null
|
||||||
|
? Optional.empty() : Optional.of(event.resolvedDate());
|
||||||
|
Optional<String> reasoning = event.aiReasoning() == null || event.aiReasoning().isBlank()
|
||||||
|
? Optional.empty() : Optional.of(event.aiReasoning());
|
||||||
|
Optional<String> failureMessage = event.failureMessage() == null || event.failureMessage().isBlank()
|
||||||
|
? Optional.empty() : Optional.of(event.failureMessage());
|
||||||
|
Duration duration = event.processingDuration();
|
||||||
|
|
||||||
|
// Historischen Kontext für übersprungene Dokumente nachladen
|
||||||
|
boolean isSkipped = event.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED
|
||||||
|
|| event.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE;
|
||||||
|
Optional<HistoricalDocumentContext> historicalContext = Optional.empty();
|
||||||
|
if (isSkipped) {
|
||||||
|
try {
|
||||||
|
historicalContext = historicalDocumentContextPort
|
||||||
|
.resolveHistoricalDocumentContext(configFilePath, event.fingerprint());
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warn("Historischer Kontext konnte nicht abgefragt werden für {}: {}",
|
||||||
|
event.originalFileName(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
// Zieldateiname für SKIPPED_ALREADY_PROCESSED aus Kontext übernehmen
|
||||||
|
if (finalName.isEmpty()) {
|
||||||
|
finalName = historicalContext
|
||||||
|
.flatMap(HistoricalDocumentContext::lastTargetFileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GuiBatchRunResultRow(
|
||||||
|
event.originalFileName(),
|
||||||
|
event.fingerprint(),
|
||||||
|
event.status(),
|
||||||
|
finalName,
|
||||||
|
Optional.empty(),
|
||||||
|
date,
|
||||||
|
reasoning,
|
||||||
|
failureMessage,
|
||||||
|
duration,
|
||||||
|
false,
|
||||||
|
historicalContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void showLockErrorAlert() {
|
||||||
|
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||||
|
alert.setTitle("Verarbeitungslauf nicht möglich");
|
||||||
|
alert.setHeaderText("Konfigurationsdatei gesperrt");
|
||||||
|
alert.setContentText(
|
||||||
|
"Der Verarbeitungslauf konnte nicht gestartet werden, da die "
|
||||||
|
+ "Konfigurationsdatei nicht gesperrt werden konnte.\n\n"
|
||||||
|
+ "Mögliche Ursache: Der automatische Scheduler ist aktiv oder "
|
||||||
|
+ "ein anderer Prozess hält die Datei belegt.\n\n"
|
||||||
|
+ "Bitte stoppen Sie den Scheduler und versuchen Sie es erneut.");
|
||||||
|
alert.showAndWait();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
|
||||||
|
return (configPath, fingerprint) -> Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Function<Runnable, Thread> defaultThreadFactory() {
|
||||||
|
return task -> {
|
||||||
|
Thread thread = new Thread(task, WORKER_THREAD_NAME);
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Consumer<Runnable> defaultFxDispatcher() {
|
||||||
|
return Platform::runLater;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiMiniRunLauncher rejectingMiniRunLauncher() {
|
||||||
|
return (configFilePath, fingerprintFilter, observer, cancellationToken) ->
|
||||||
|
GuiBatchRunLaunchOutcome.rejected(
|
||||||
|
"Kein Mini-Run-Launcher in diesem Kontext verfügbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GuiResetDocumentStatusPort rejectingResetPort() {
|
||||||
|
return (configFilePath, fingerprints) ->
|
||||||
|
new ResetDocumentStatusResult(fingerprints.size(),
|
||||||
|
Set.of(), allFailureMapStatic(fingerprints,
|
||||||
|
"Kein Reset-Port in diesem Kontext verfügbar."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.util.Map<DocumentFingerprint, String> allFailureMapStatic(
|
||||||
|
Set<DocumentFingerprint> fingerprints, String message) {
|
||||||
|
java.util.Map<DocumentFingerprint, String> map = new java.util.HashMap<>();
|
||||||
|
for (DocumentFingerprint fp : fingerprints) {
|
||||||
|
map.put(fp, message);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable result of a single batch run launched from the GUI.
|
||||||
|
* <p>
|
||||||
|
* The outcome reports to the tab whether the run finished normally, could not even be
|
||||||
|
* started (hard failure), or ended because of an unexpected exception. The GUI uses this
|
||||||
|
* to transition between its "laufend" and "bereit"/"Fehler" states.
|
||||||
|
*
|
||||||
|
* <h2>Fields</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #successfullyStarted()} — {@code true} when the launcher managed to enter
|
||||||
|
* the batch execution phase; {@code false} when the run was rejected before any
|
||||||
|
* candidate could be processed (e.g. configuration invalid, lock held, SQLite
|
||||||
|
* unavailable).</li>
|
||||||
|
* <li>{@link #batchCompletedNormally()} — {@code true} when the run returned from the
|
||||||
|
* batch use case with a normal outcome (whether empty, partial, or full). Only
|
||||||
|
* meaningful when {@link #successfullyStarted()} is also {@code true}.</li>
|
||||||
|
* <li>{@link #failureMessage()} — present when either the run could not start or an
|
||||||
|
* unexpected technical exception terminated it. Empty when the run completed
|
||||||
|
* normally.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public record GuiBatchRunLaunchOutcome(
|
||||||
|
boolean successfullyStarted,
|
||||||
|
boolean batchCompletedNormally,
|
||||||
|
Optional<String> failureMessage) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact constructor normalising the failure message holder.
|
||||||
|
*/
|
||||||
|
public GuiBatchRunLaunchOutcome {
|
||||||
|
failureMessage = Objects.requireNonNullElse(failureMessage, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an outcome describing a run that finished normally.
|
||||||
|
*
|
||||||
|
* @return a started + completed outcome without failure message
|
||||||
|
*/
|
||||||
|
public static GuiBatchRunLaunchOutcome completed() {
|
||||||
|
return new GuiBatchRunLaunchOutcome(true, true, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an outcome describing a run that could not start because of a hard
|
||||||
|
* configuration, persistence, or lock failure.
|
||||||
|
*
|
||||||
|
* @param failureMessage the user-visible German failure description; must not be blank
|
||||||
|
* @return a rejected-startup outcome carrying the supplied message
|
||||||
|
*/
|
||||||
|
public static GuiBatchRunLaunchOutcome rejected(String failureMessage) {
|
||||||
|
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
|
||||||
|
if (failureMessage.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("failureMessage must not be blank");
|
||||||
|
}
|
||||||
|
return new GuiBatchRunLaunchOutcome(false, false, Optional.of(failureMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an outcome describing a run that started but ended due to an unexpected
|
||||||
|
* technical exception.
|
||||||
|
*
|
||||||
|
* @param failureMessage the user-visible German failure description; must not be blank
|
||||||
|
* @return an aborted-after-start outcome carrying the supplied message
|
||||||
|
*/
|
||||||
|
public static GuiBatchRunLaunchOutcome failedAfterStart(String failureMessage) {
|
||||||
|
Objects.requireNonNull(failureMessage, "failureMessage must not be null");
|
||||||
|
if (failureMessage.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("failureMessage must not be blank");
|
||||||
|
}
|
||||||
|
return new GuiBatchRunLaunchOutcome(true, false, Optional.of(failureMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound bridge implemented by Bootstrap to let the GUI execute a batch run against a
|
||||||
|
* stored configuration file.
|
||||||
|
* <p>
|
||||||
|
* The launcher performs the complete headless startup sequence (legacy migration, config
|
||||||
|
* loading, validation, SQLite schema initialisation, run-lock, use-case wiring, execution)
|
||||||
|
* for the supplied configuration path while forwarding progress callbacks and honouring
|
||||||
|
* the supplied cancellation token. It reuses the very same application ports and
|
||||||
|
* persistence pipeline as a Task-Scheduler-triggered headless run; only the presentation
|
||||||
|
* side (the GUI) differs.
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must be safe to call from a non-UI worker thread. They must not touch
|
||||||
|
* the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
|
||||||
|
* caller's concern. The call blocks until the run terminates (normally, after a
|
||||||
|
* cancellation, or after a hard failure).
|
||||||
|
*
|
||||||
|
* <h2>Exception contract</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
|
||||||
|
* should be caught, logged, and returned as a
|
||||||
|
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
|
||||||
|
* well-defined terminal state.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiBatchRunLauncher {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes exactly one batch run against the supplied configuration file.
|
||||||
|
*
|
||||||
|
* @param configFilePath path of the {@code .properties} file to run against;
|
||||||
|
* must not be {@code null}; must exist and be readable
|
||||||
|
* @param observer observer receiving start/completion/end callbacks; must
|
||||||
|
* not be {@code null}
|
||||||
|
* @param cancellationToken cancellation token the run polls between candidates; must
|
||||||
|
* not be {@code null}
|
||||||
|
* @return a description of how the run terminated; never {@code null}
|
||||||
|
*/
|
||||||
|
GuiBatchRunLaunchOutcome launch(
|
||||||
|
Path configFilePath,
|
||||||
|
BatchRunProgressObserver observer,
|
||||||
|
BatchRunCancellationToken cancellationToken);
|
||||||
|
}
|
||||||
+285
@@ -0,0 +1,285 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable view model for a single row in the processing-run result list.
|
||||||
|
* <p>
|
||||||
|
* Each completed candidate becomes exactly one row. The row carries only the information
|
||||||
|
* that is shown in the list and the side panel; it is decoupled from the persistence
|
||||||
|
* model so later GUI layers can render it without reaching back into the application
|
||||||
|
* layer.
|
||||||
|
* <p>
|
||||||
|
* The {@code fingerprint} field is the content-based identity of the document and is
|
||||||
|
* used as a stable key for in-place row updates during a targeted mini-run.
|
||||||
|
* <p>
|
||||||
|
* When {@code resetPending} is {@code true} the row represents a document whose
|
||||||
|
* persistence status has been deleted but which has not yet been reprocessed. The status
|
||||||
|
* icon and label reflect this special state instead of the original processing outcome.
|
||||||
|
*
|
||||||
|
* @param originalFileName the source filename as reported by the use case; never
|
||||||
|
* {@code null} or blank
|
||||||
|
* @param fingerprint the content-based identity of the processed document; never
|
||||||
|
* {@code null}
|
||||||
|
* @param status the aggregated completion status; never {@code null}
|
||||||
|
* @param finalFileName the final target filename when the row represents a successful
|
||||||
|
* rename; empty otherwise
|
||||||
|
* @param correctedFileName Der manuell korrigierte Zieldateiname, falls der Benutzer den
|
||||||
|
* KI-Vorschlag in der GUI bearbeitet und gespeichert hat.
|
||||||
|
* Leer bei unverändertem KI-Vorschlag.
|
||||||
|
* @param resolvedDate the resolved document date when the row represents a successful
|
||||||
|
* rename; empty otherwise
|
||||||
|
* @param aiReasoning the AI reasoning shown in the side panel; empty when no
|
||||||
|
* reasoning is available for this row
|
||||||
|
* @param aiFailureMessage eine lesbare Fehlerbeschreibung, wenn der KI-Aufruf oder die
|
||||||
|
* Verarbeitung fehlgeschlagen ist; leer bei Erfolg und
|
||||||
|
* übersprungenen Dokumenten
|
||||||
|
* @param processingDuration wall-clock duration spent on the candidate in this run;
|
||||||
|
* never {@code null} and never negative
|
||||||
|
* @param resetPending {@code true} when the document's persistence status has been
|
||||||
|
* reset and is awaiting the next processing run
|
||||||
|
* @param historicalContext historischer Verarbeitungskontext für übersprungene Dokumente;
|
||||||
|
* leer bei nicht-übersprungenen Zeilen
|
||||||
|
*/
|
||||||
|
public record GuiBatchRunResultRow(
|
||||||
|
String originalFileName,
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
|
DocumentCompletionStatus status,
|
||||||
|
Optional<String> finalFileName,
|
||||||
|
Optional<String> correctedFileName,
|
||||||
|
Optional<LocalDate> resolvedDate,
|
||||||
|
Optional<String> aiReasoning,
|
||||||
|
Optional<String> aiFailureMessage,
|
||||||
|
Duration processingDuration,
|
||||||
|
boolean resetPending,
|
||||||
|
Optional<HistoricalDocumentContext> historicalContext) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label shown in the status column when a document's persistence status has been
|
||||||
|
* reset and is waiting for the next processing run.
|
||||||
|
*/
|
||||||
|
static final String RESET_PENDING_LABEL = "Zurückgesetzt – wartet auf nächsten Lauf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon shown in the status column when a document's persistence status has been reset.
|
||||||
|
*/
|
||||||
|
static final String RESET_PENDING_ICON = "⟳"; // ⟳ CLOCKWISE GAPPED CIRCLE ARROW
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact constructor normalising optional holders and validating mandatory fields.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException if {@code originalFileName}, {@code fingerprint},
|
||||||
|
* {@code status} or {@code processingDuration} is
|
||||||
|
* {@code null}
|
||||||
|
* @throws IllegalArgumentException if {@code originalFileName} is blank or
|
||||||
|
* {@code processingDuration} is negative
|
||||||
|
*/
|
||||||
|
public GuiBatchRunResultRow {
|
||||||
|
Objects.requireNonNull(originalFileName, "originalFileName must not be null");
|
||||||
|
if (originalFileName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("originalFileName must not be blank");
|
||||||
|
}
|
||||||
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
|
finalFileName = Objects.requireNonNullElse(finalFileName, Optional.empty());
|
||||||
|
correctedFileName = Objects.requireNonNullElse(correctedFileName, Optional.empty());
|
||||||
|
resolvedDate = Objects.requireNonNullElse(resolvedDate, Optional.empty());
|
||||||
|
aiReasoning = Objects.requireNonNullElse(aiReasoning, Optional.empty());
|
||||||
|
aiFailureMessage = Objects.requireNonNullElse(aiFailureMessage, Optional.empty());
|
||||||
|
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
||||||
|
if (processingDuration.isNegative()) {
|
||||||
|
throw new IllegalArgumentException("processingDuration must not be negative");
|
||||||
|
}
|
||||||
|
historicalContext = Objects.requireNonNullElse(historicalContext, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bequem-Konstruktor für Zeilen, die weder einen manuell korrigierten Dateinamen
|
||||||
|
* tragen noch im reset-pending-Zustand stehen und keinen historischen Kontext haben.
|
||||||
|
*
|
||||||
|
* @param originalFileName the source filename; never {@code null} or blank
|
||||||
|
* @param fingerprint the content-based document identity; never {@code null}
|
||||||
|
* @param status the aggregated completion status; never {@code null}
|
||||||
|
* @param finalFileName the final target filename; may be {@code null} (treated as
|
||||||
|
* empty)
|
||||||
|
* @param resolvedDate the resolved document date; may be {@code null} (treated as
|
||||||
|
* empty)
|
||||||
|
* @param aiReasoning the AI reasoning text; may be {@code null} (treated as
|
||||||
|
* empty)
|
||||||
|
* @param aiFailureMessage eine lesbare Fehlerbeschreibung bei Fehler; may be
|
||||||
|
* {@code null} (treated as empty)
|
||||||
|
* @param processingDuration the wall-clock processing duration; never {@code null}
|
||||||
|
*/
|
||||||
|
public GuiBatchRunResultRow(
|
||||||
|
String originalFileName,
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
|
DocumentCompletionStatus status,
|
||||||
|
Optional<String> finalFileName,
|
||||||
|
Optional<LocalDate> resolvedDate,
|
||||||
|
Optional<String> aiReasoning,
|
||||||
|
Optional<String> aiFailureMessage,
|
||||||
|
Duration processingDuration) {
|
||||||
|
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
|
||||||
|
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, false,
|
||||||
|
Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bequem-Konstruktor mit explizitem {@code resetPending}-Flag, aber ohne manuell
|
||||||
|
* korrigierten Dateinamen und ohne historischen Kontext.
|
||||||
|
*
|
||||||
|
* @param originalFileName the source filename; never {@code null} or blank
|
||||||
|
* @param fingerprint the content-based document identity; never {@code null}
|
||||||
|
* @param status the aggregated completion status; never {@code null}
|
||||||
|
* @param finalFileName the final target filename; may be {@code null} (treated as
|
||||||
|
* empty)
|
||||||
|
* @param resolvedDate the resolved document date; may be {@code null} (treated as
|
||||||
|
* empty)
|
||||||
|
* @param aiReasoning the AI reasoning text; may be {@code null} (treated as
|
||||||
|
* empty)
|
||||||
|
* @param aiFailureMessage eine lesbare Fehlerbeschreibung bei Fehler; may be
|
||||||
|
* {@code null} (treated as empty)
|
||||||
|
* @param processingDuration the wall-clock processing duration; never {@code null}
|
||||||
|
* @param resetPending {@code true} wenn der Stammsatz zurückgesetzt wurde
|
||||||
|
*/
|
||||||
|
public GuiBatchRunResultRow(
|
||||||
|
String originalFileName,
|
||||||
|
DocumentFingerprint fingerprint,
|
||||||
|
DocumentCompletionStatus status,
|
||||||
|
Optional<String> finalFileName,
|
||||||
|
Optional<LocalDate> resolvedDate,
|
||||||
|
Optional<String> aiReasoning,
|
||||||
|
Optional<String> aiFailureMessage,
|
||||||
|
Duration processingDuration,
|
||||||
|
boolean resetPending) {
|
||||||
|
this(originalFileName, fingerprint, status, finalFileName, Optional.empty(),
|
||||||
|
resolvedDate, aiReasoning, aiFailureMessage, processingDuration, resetPending,
|
||||||
|
Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reset-pending copy of the supplied row, preserving the original filename
|
||||||
|
* and fingerprint while marking the row as awaiting the next processing run.
|
||||||
|
* <p>
|
||||||
|
* The returned row has {@code resetPending == true}. Its {@code statusIcon()} and
|
||||||
|
* {@code statusLabel()} reflect the reset state.
|
||||||
|
*
|
||||||
|
* @param previousRow the row to copy; must not be {@code null}
|
||||||
|
* @return a new row with the same filename and fingerprint, {@code resetPending == true}
|
||||||
|
* @throws NullPointerException if {@code previousRow} is {@code null}
|
||||||
|
*/
|
||||||
|
public static GuiBatchRunResultRow resetMarker(GuiBatchRunResultRow previousRow) {
|
||||||
|
Objects.requireNonNull(previousRow, "previousRow must not be null");
|
||||||
|
return new GuiBatchRunResultRow(
|
||||||
|
previousRow.originalFileName(),
|
||||||
|
previousRow.fingerprint(),
|
||||||
|
previousRow.status(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Duration.ZERO,
|
||||||
|
true,
|
||||||
|
Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das Status-Icon für diese Zeile als Unicode-Zeichen zurück, das in JavaFX
|
||||||
|
* unter Windows zuverlässig dargestellt wird (16px, bold).
|
||||||
|
* <p>
|
||||||
|
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
||||||
|
* eigentlichen Status das Reset-Icon zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Die Icon-Werte stammen aus {@link ProcessingStatusPresentation}.
|
||||||
|
*
|
||||||
|
* @return das entsprechende Status-Zeichen
|
||||||
|
*/
|
||||||
|
public String statusIcon() {
|
||||||
|
if (resetPending) {
|
||||||
|
return RESET_PENDING_ICON;
|
||||||
|
}
|
||||||
|
return ProcessingStatusPresentation.iconFor(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die CSS-Farbe für das Status-Icon dieser Zeile zurück.
|
||||||
|
* <p>
|
||||||
|
* Wenn {@code resetPending} den Wert {@code true} hat, wird unabhängig vom
|
||||||
|
* eigentlichen Status die Reset-Farbe zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Farbe ist niemals das einzige Unterscheidungsmerkmal – {@link #statusIcon()} und
|
||||||
|
* {@link #statusTooltip()} beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
|
||||||
|
* Die Farbwerte stammen aus {@link ProcessingStatusPresentation}.
|
||||||
|
*
|
||||||
|
* @return die entsprechende CSS-Hex-Farbe (z. B. {@code "#2e7d32"})
|
||||||
|
*/
|
||||||
|
public String statusColor() {
|
||||||
|
if (resetPending) {
|
||||||
|
return "#757575"; // Grau für Reset-pending
|
||||||
|
}
|
||||||
|
return ProcessingStatusPresentation.cssColorFor(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den deutschsprachigen Tooltip-Text für den Verarbeitungsstatus dieser Zeile zurück.
|
||||||
|
* <p>
|
||||||
|
* Wenn {@code resetPending} den Wert {@code true} hat, wird ein Tooltip für den
|
||||||
|
* Reset-Zustand zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Der Tooltip-Text beschreibt den Status vollständig ohne Farbe. Die Texte stammen
|
||||||
|
* aus {@link ProcessingStatusPresentation}.
|
||||||
|
*
|
||||||
|
* @return der Tooltip-Text; nie leer
|
||||||
|
*/
|
||||||
|
public String statusTooltip() {
|
||||||
|
if (resetPending) {
|
||||||
|
return RESET_PENDING_LABEL;
|
||||||
|
}
|
||||||
|
return ProcessingStatusPresentation.tooltipFor(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the human-readable status label for this row.
|
||||||
|
* <p>
|
||||||
|
* When {@code resetPending} is {@code true} the reset-pending label is returned
|
||||||
|
* regardless of the underlying status.
|
||||||
|
*
|
||||||
|
* @return a non-null German status label
|
||||||
|
*/
|
||||||
|
public String statusLabel() {
|
||||||
|
if (resetPending) {
|
||||||
|
return RESET_PENDING_LABEL;
|
||||||
|
}
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "Erfolgreich";
|
||||||
|
case FAILED_RETRYABLE -> "Fehlgeschlagen (wiederholbar)";
|
||||||
|
case FAILED_PERMANENT -> "Fehlgeschlagen (dauerhaft)";
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> "Übersprungen (bereits verarbeitet)";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> "Übersprungen (endgültig fehlgeschlagen)";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den aktuell wirksamen Zieldateinamen: falls der Benutzer den KI-Vorschlag
|
||||||
|
* manuell korrigiert und gespeichert hat, wird der korrigierte Name geliefert,
|
||||||
|
* ansonsten der ursprüngliche KI-Vorschlag {@link #finalFileName()}.
|
||||||
|
* <p>
|
||||||
|
* Die Tabellenspalte „Neuer Dateiname" bindet an diesen Wert.
|
||||||
|
*
|
||||||
|
* @return den aktuell anzuzeigenden Zieldateinamen; leer wenn kein Name vorliegt
|
||||||
|
*/
|
||||||
|
public Optional<String> effectiveFileName() {
|
||||||
|
if (correctedFileName.isPresent()) {
|
||||||
|
return correctedFileName;
|
||||||
|
}
|
||||||
|
return finalFileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1847
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.HistoricalDocumentContext;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-interner Port zum Abfragen des historischen Verarbeitungskontexts einer Quelldatei.
|
||||||
|
* <p>
|
||||||
|
* Wird im Verarbeitungslauf-Tab genutzt, um für übersprungene Dokumente
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}
|
||||||
|
* und
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED_FINAL_FAILURE})
|
||||||
|
* den historischen Kontext nachzuschlagen. Der Kontext wird im Detailbereich des
|
||||||
|
* Verarbeitungslauf-Tabs angezeigt.
|
||||||
|
* <p>
|
||||||
|
* Die Bootstrap-Schicht liefert die konkrete Implementierung. Sie lädt die
|
||||||
|
* Konfiguration aus {@code configFilePath}, baut den zugehörigen Use-Case auf und
|
||||||
|
* gibt das Ergebnis zurück. Technische Fehler beim Laden oder Abfragen werden intern
|
||||||
|
* abgefangen und als leeres {@link Optional} zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Die Implementierung läuft auf dem Worker-Thread des {@link GuiBatchRunCoordinator}
|
||||||
|
* und darf blockieren.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoricalDocumentContextPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den historischen Verarbeitungskontext für das durch {@code fingerprint}
|
||||||
|
* identifizierte Dokument zurück, oder ein leeres {@link Optional}, wenn kein
|
||||||
|
* Kontext verfügbar ist.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktiven {@code .properties}-Konfigurationsdatei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
|
||||||
|
* @return historischer Kontext des Dokuments, oder leer wenn nicht verfügbar
|
||||||
|
*/
|
||||||
|
Optional<HistoricalDocumentContext> resolveHistoricalDocumentContext(
|
||||||
|
Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-interner Port zum Abfragen des historischen KI-Dateinamens einer Quelldatei.
|
||||||
|
* <p>
|
||||||
|
* Wird im Verarbeitungslauf-Tab genutzt, um für übersprungene Dokumente
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus#SKIPPED})
|
||||||
|
* den aus einem früheren Lauf bekannten Zieldateinamen nachzuschlagen und in der Spalte
|
||||||
|
* „Neuer Dateiname" der Ergebnistabelle anzuzeigen.
|
||||||
|
* <p>
|
||||||
|
* Die Bootstrap-Schicht liefert die konkrete Implementierung. Sie lädt die Konfiguration
|
||||||
|
* aus {@code configFilePath}, baut den zugehörigen Use-Case auf und gibt das Ergebnis
|
||||||
|
* zurück. Technische Fehler beim Laden oder Abfragen dürfen nicht als Exception propagiert
|
||||||
|
* werden; sie werden intern behandelt und als leeres {@link Optional} zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Die Implementierung läuft auf dem Worker-Thread des {@link GuiBatchRunCoordinator}
|
||||||
|
* und darf blockieren.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoricalFileNamePort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den letzten erfolgreich geschriebenen Zieldateinamen für das durch
|
||||||
|
* {@code fingerprint} identifizierte Dokument zurück, oder ein leeres
|
||||||
|
* {@link Optional}, wenn kein solcher Name verfügbar ist.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktiven {@code .properties}-Konfigurationsdatei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint inhaltsbasierte Dokumentenidentität; darf nicht {@code null} sein
|
||||||
|
* @return der historische Zieldateiname, oder leer wenn nicht vorhanden
|
||||||
|
*/
|
||||||
|
Optional<String> resolveHistoricalFileName(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyRequest;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileCopyResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound-Brücke für die manuelle Kopie der Quelldatei eines bislang nicht erfolgreich
|
||||||
|
* verarbeiteten Dokuments aus der GUI.
|
||||||
|
* <p>
|
||||||
|
* Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen, wenn der
|
||||||
|
* Benutzer für ein nicht erfolgreich verarbeitetes Dokument (Status {@code FAILED_*} oder
|
||||||
|
* {@code SKIPPED_FINAL_FAILURE}) einen manuellen Zieldateinamen bestätigt. Der Port
|
||||||
|
* kapselt das vollständige Wiring (Konfigurationsauflösung, Use-Case-Konstruktion und
|
||||||
|
* Ausführung), sodass der GUI-Adapter keine Kenntnis von infrastrukturellen
|
||||||
|
* Implementierungsdetails benötigt.
|
||||||
|
*
|
||||||
|
* <h2>Threadingmodell</h2>
|
||||||
|
* <p>
|
||||||
|
* Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung ist
|
||||||
|
* synchron und blockierend: Sie kehrt erst zurück, wenn die Kopie abgeschlossen oder
|
||||||
|
* fehlgeschlagen ist. Aufrufer aus dem GUI-Layer müssen den Aufruf daher auf einem
|
||||||
|
* Hintergrund-Worker-Thread ausführen und das Ergebnis anschließend per
|
||||||
|
* {@code Platform.runLater} auf den JavaFX-Application-Thread zurückführen.
|
||||||
|
*
|
||||||
|
* <h2>Exception-Vertrag</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete
|
||||||
|
* Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileCopyResult}
|
||||||
|
* zurückgegeben werden.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiManualFileCopyPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopiert die Quelldatei eines Dokuments mit benutzerdefiniertem Zieldateinamen ins
|
||||||
|
* Zielverzeichnis und aktualisiert den Dokument-Stammsatz auf {@code SUCCESS}.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur {@code .properties}-Datei, die SQLite-Datenbank,
|
||||||
|
* Quell- und Zielordner beschreibt; darf nicht {@code null} sein;
|
||||||
|
* muss existieren und lesbar sein
|
||||||
|
* @param request die Kopieranfrage mit Fingerprint und gewünschtem
|
||||||
|
* Basisdateinamen; darf nicht {@code null} sein
|
||||||
|
* @return das Ergebnis der Kopieroperation; nie {@code null}
|
||||||
|
*/
|
||||||
|
ManualFileCopyResult copy(Path configFilePath, ManualFileCopyRequest request);
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameRequest;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound-Brücke für die manuelle Dateiumbenennung aus der GUI.
|
||||||
|
* <p>
|
||||||
|
* Wird von Bootstrap per Methoden-Referenz befüllt und vom GUI-Code aufgerufen,
|
||||||
|
* wenn der Benutzer einen geänderten Dateinamen bestätigt. Der Port kapselt
|
||||||
|
* das vollständige Wiring (Konfigurationsauflösung, Use-Case-Konstruktion und
|
||||||
|
* Ausführung), sodass der GUI-Adapter keine Kenntnis von infrastrukturellen
|
||||||
|
* Implementierungsdetails benötigt.
|
||||||
|
*
|
||||||
|
* <h2>Threadingmodell</h2>
|
||||||
|
* <p>
|
||||||
|
* Der Port darf auf einem beliebigen Thread aufgerufen werden. Die Implementierung
|
||||||
|
* ist synchron und blockierend: Sie kehrt erst zurück, wenn die Umbenennung
|
||||||
|
* abgeschlossen oder fehlgeschlagen ist. Aufrufer aus dem GUI-Layer müssen den
|
||||||
|
* Aufruf daher auf einem Hintergrund-Worker-Thread ausführen und das Ergebnis
|
||||||
|
* anschließend per {@code Platform.runLater} auf den JavaFX-Application-Thread
|
||||||
|
* zurückführen.
|
||||||
|
*
|
||||||
|
* <h2>Exception-Vertrag</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementierungen dürfen keine geprüften Ausnahmen propagieren. Unerwartete
|
||||||
|
* Laufzeitausnahmen sollen abgefangen und als passendes {@link ManualFileRenameResult}
|
||||||
|
* zurückgegeben werden.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiManualFileRenamePort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benennt die Zieldatei eines erfolgreich verarbeiteten Dokuments manuell um.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur {@code .properties}-Datei, die die SQLite-Datenbank
|
||||||
|
* und den Zielordner beschreibt; darf nicht {@code null} sein;
|
||||||
|
* muss existieren und lesbar sein
|
||||||
|
* @param request die Umbenennungsanfrage mit Fingerprint und gewünschtem
|
||||||
|
* Basisdateinamen; darf nicht {@code null} sein
|
||||||
|
* @return das Ergebnis der Umbenennung; nie {@code null}
|
||||||
|
*/
|
||||||
|
ManualFileRenameResult rename(Path configFilePath, ManualFileRenameRequest request);
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound bridge implemented by Bootstrap to let the GUI execute a targeted mini batch
|
||||||
|
* run restricted to a specific set of document fingerprints.
|
||||||
|
* <p>
|
||||||
|
* A mini-run applies the full processing pipeline — legacy migration, configuration
|
||||||
|
* loading, validation, SQLite schema initialisation, run-lock, use-case wiring, and
|
||||||
|
* execution — but limits processing to the supplied fingerprint set. Documents not in
|
||||||
|
* the set are silently skipped without any persistence side-effects.
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must be safe to call from a non-UI worker thread. They must not touch
|
||||||
|
* the JavaFX Application Thread themselves; all JavaFX-specific scheduling is the
|
||||||
|
* caller's concern. The call blocks until the run terminates (normally, after a
|
||||||
|
* cancellation, or after a hard failure).
|
||||||
|
*
|
||||||
|
* <h2>Exception contract</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
|
||||||
|
* should be caught, logged, and returned as a
|
||||||
|
* {@link GuiBatchRunLaunchOutcome#failedAfterStart(String)} outcome to keep the GUI in a
|
||||||
|
* well-defined terminal state.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiMiniRunLauncher {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a targeted batch run restricted to the supplied fingerprint set.
|
||||||
|
*
|
||||||
|
* @param configFilePath path of the {@code .properties} file to run against;
|
||||||
|
* must not be {@code null}; must exist and be readable
|
||||||
|
* @param fingerprintFilter the set of document fingerprints to process; must not be
|
||||||
|
* {@code null}; an empty set results in a completed run
|
||||||
|
* that processes nothing
|
||||||
|
* @param observer observer receiving start/completion/end callbacks; must
|
||||||
|
* not be {@code null}
|
||||||
|
* @param cancellationToken cancellation token the run polls between candidates; must
|
||||||
|
* not be {@code null}
|
||||||
|
* @return a description of how the run terminated; never {@code null}
|
||||||
|
*/
|
||||||
|
GuiBatchRunLaunchOutcome launch(
|
||||||
|
Path configFilePath,
|
||||||
|
Set<DocumentFingerprint> fingerprintFilter,
|
||||||
|
BatchRunProgressObserver observer,
|
||||||
|
BatchRunCancellationToken cancellationToken);
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound bridge implemented by Bootstrap to let the GUI reset the processing status
|
||||||
|
* of one or more documents without triggering an immediate reprocessing run.
|
||||||
|
* <p>
|
||||||
|
* A reset deletes all persistence data (attempt history and document master record)
|
||||||
|
* for the specified fingerprints, making them eligible for reprocessing in the next
|
||||||
|
* regular or targeted batch run as if they had never been processed.
|
||||||
|
* <p>
|
||||||
|
* The operation follows best-effort semantics: each fingerprint is attempted
|
||||||
|
* independently. Technical failures for individual fingerprints are recorded in the
|
||||||
|
* result and do not abort the remaining resets.
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must be safe to call from a non-UI worker thread. The call blocks
|
||||||
|
* until all reset operations have completed or failed.
|
||||||
|
*
|
||||||
|
* <h2>Exception contract</h2>
|
||||||
|
* <p>
|
||||||
|
* Implementations must not propagate checked exceptions. Unexpected runtime exceptions
|
||||||
|
* should be caught and represented as failures in the result map.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiResetDocumentStatusPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the processing status for the supplied set of document fingerprints.
|
||||||
|
*
|
||||||
|
* @param configFilePath path of the {@code .properties} file that identifies the
|
||||||
|
* SQLite database to operate on; must not be {@code null};
|
||||||
|
* must exist and be readable
|
||||||
|
* @param fingerprints the set of document fingerprints to reset; must not be
|
||||||
|
* {@code null}; may be empty
|
||||||
|
* @return a {@link ResetDocumentStatusResult} describing the full outcome; never null
|
||||||
|
*/
|
||||||
|
ResetDocumentStatusResult reset(Path configFilePath, Set<DocumentFingerprint> fingerprints);
|
||||||
|
}
|
||||||
+840
@@ -0,0 +1,840 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.apache.pdfbox.Loader;
|
||||||
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
|
import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException;
|
||||||
|
import org.apache.pdfbox.rendering.ImageType;
|
||||||
|
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||||
|
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.embed.swing.SwingFXUtils;
|
||||||
|
import javafx.geometry.Bounds;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
|
||||||
|
import javafx.scene.Cursor;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ProgressIndicator;
|
||||||
|
import javafx.scene.control.ScrollPane;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
import javafx.scene.image.Image;
|
||||||
|
import javafx.scene.image.ImageView;
|
||||||
|
import javafx.scene.input.MouseEvent;
|
||||||
|
import javafx.scene.input.ScrollEvent;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.Region;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
|
||||||
|
*
|
||||||
|
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
|
||||||
|
* in einer {@link ImageView} an. Im Fit-to-View-Modus (Standardzustand) sind
|
||||||
|
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} an die Größe des
|
||||||
|
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
|
||||||
|
* Seitenverhältnis. Die Seite füllt den verfügbaren Bereich ohne Scrollbalken.
|
||||||
|
*
|
||||||
|
* <p><strong>Mausrad-Zoom:</strong> Strg + Mausrad ändert den Zoomfaktor in Stufen von
|
||||||
|
* 10 % pro Raste (Bereich {@value #ZOOM_MIN}–{@value #ZOOM_MAX}, d. h. 10 %–500 %).
|
||||||
|
* Beim ersten manuellen Zoom wird der Fit-to-View-Modus verlassen und ein
|
||||||
|
* {@link ScrollPane} übernimmt das Scrollen. Das Laden einer neuen Datei setzt den
|
||||||
|
* Zoom automatisch auf Fit-to-View zurück.
|
||||||
|
*
|
||||||
|
* <p><strong>Grab & Pan:</strong> Im manuellen Zoom-Modus kann die Vorschau durch
|
||||||
|
* Klicken und Ziehen (linke Maustaste) verschoben werden. Der Mauszeiger wechselt im
|
||||||
|
* Zoom-Modus auf {@link Cursor#OPEN_HAND} und während der Geste auf
|
||||||
|
* {@link Cursor#CLOSED_HAND}.
|
||||||
|
*
|
||||||
|
* <p>Das Laden der PDF-Datei und das Rendering einzelner Seiten erfolgt auf einem
|
||||||
|
* dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX
|
||||||
|
* Application Thread. Bereits gerenderte Seiten werden in einem In-Memory-Cache
|
||||||
|
* ({@code Map<Integer, Image>}) gehalten, sodass wiederholte Navigation kein
|
||||||
|
* erneutes Rendering erfordert. Der Cache wird beim Wechsel der Quelldatei geleert.
|
||||||
|
*
|
||||||
|
* <p>Es gilt das Prinzip „Latest Preview Request Wins": Veraltete Lade- und
|
||||||
|
* Rendering-Ergebnisse werden anhand einer Sequenznummer erkannt und verworfen,
|
||||||
|
* sobald eine neue Anforderung eingeht.
|
||||||
|
*
|
||||||
|
* <h2>Fehlerfälle</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Quelldatei nicht vorhanden → Meldungstext im Vorschaubereich</li>
|
||||||
|
* <li>PDF nicht lesbar → Meldungstext im Vorschaubereich</li>
|
||||||
|
* <li>PDF passwortgeschützt → Meldungstext im Vorschaubereich</li>
|
||||||
|
* <li>Keine Selektion → neutraler Platzhaltertext</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Threading</h2>
|
||||||
|
* <p>Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
|
||||||
|
* werden. Das PDF-Öffnen, die Speicherhaltung des {@link PDDocument} und das
|
||||||
|
* Rendering einzelner Seiten laufen ausschließlich auf dem Worker-Thread.
|
||||||
|
*/
|
||||||
|
public final class PdfPreviewPane {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(PdfPreviewPane.class);
|
||||||
|
|
||||||
|
static final String PLACEHOLDER_TEXT = "Keine Datei ausgewählt";
|
||||||
|
static final String FILE_NOT_FOUND_TEXT = "Quelldatei nicht gefunden";
|
||||||
|
static final String PDF_UNREADABLE_TEXT = "PDF konnte nicht geöffnet werden";
|
||||||
|
static final String PDF_PASSWORD_PROTECTED_TEXT =
|
||||||
|
"PDF ist passwortgeschützt und kann nicht angezeigt werden";
|
||||||
|
|
||||||
|
/** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
|
||||||
|
private static final float RENDER_DPI = 120f;
|
||||||
|
|
||||||
|
/** Minimaler Zoomfaktor (10 %). */
|
||||||
|
static final double ZOOM_MIN = 0.10;
|
||||||
|
|
||||||
|
/** Maximaler Zoomfaktor (500 %). */
|
||||||
|
static final double ZOOM_MAX = 5.00;
|
||||||
|
|
||||||
|
/** Zoom-Schrittgröße pro Mausrad-Raste (10 %). */
|
||||||
|
private static final double ZOOM_STEP = 0.10;
|
||||||
|
|
||||||
|
/** Typischer vertikaler Scroll-Delta pro Mausrad-Raste. */
|
||||||
|
private static final double ZOOM_NOTCH_THRESHOLD = 40.0;
|
||||||
|
|
||||||
|
private final VBox root = new VBox(4);
|
||||||
|
private final StackPane viewStack = new StackPane();
|
||||||
|
private final ImageView imageView = new ImageView();
|
||||||
|
private final Label overlayLabel = new Label(PLACEHOLDER_TEXT);
|
||||||
|
private final ProgressIndicator progressIndicator = new ProgressIndicator();
|
||||||
|
private final Label pageLabel = new Label();
|
||||||
|
private final Button prevButton = new Button("◀ Vorherige");
|
||||||
|
private final Button nextButton = new Button("Nächste ▶");
|
||||||
|
private final Label sectionTitle = new Label("PDF-Vorschau");
|
||||||
|
private final ScrollPane scrollPane = new ScrollPane(viewStack);
|
||||||
|
|
||||||
|
/** Aktueller Zoomfaktor; 1.0 entspricht der natürlichen Viewport-Breite. */
|
||||||
|
private double zoomLevel = 1.0;
|
||||||
|
|
||||||
|
/** Akkumulator für sub-Rasten-Scroll-Deltas. */
|
||||||
|
private double zoomAccumulator = 0.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenzbreite für die manuelle Zoom-Skalierung; gilt
|
||||||
|
* {@code imageView.fitWidth = naturalViewportWidth × zoomLevel} im manuellen
|
||||||
|
* Zoom-Modus. Beim Verlassen des Fit-Modus wird der Wert auf die natürliche
|
||||||
|
* Bildbreite gesetzt, sodass {@code zoomLevel = 1.0} der pixel-genauen
|
||||||
|
* Originalgröße entspricht und {@code zoomLevel} damit gleich dem visuellen
|
||||||
|
* Skalierungsfaktor ist. {@code 0.0} bedeutet Fit-to-View-Modus ist aktiv.
|
||||||
|
*/
|
||||||
|
private double naturalViewportWidth = 0.0;
|
||||||
|
|
||||||
|
/** X-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
|
||||||
|
private double panStartX = -1;
|
||||||
|
|
||||||
|
/** Y-Startposition der laufenden Pan-Geste in Bildschirmkoordinaten; -1 wenn inaktiv. */
|
||||||
|
private double panStartY = -1;
|
||||||
|
|
||||||
|
/** Horizontaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
|
||||||
|
private double panStartHvalue = 0.0;
|
||||||
|
|
||||||
|
/** Vertikaler Scroll-Wert zu Beginn der laufenden Pan-Geste. */
|
||||||
|
private double panStartVvalue = 0.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
|
||||||
|
* (Laden oder Seitenwechsel) erhöht diesen Zähler. Lade-/Rendering-Ergebnisse
|
||||||
|
* mit veralteter Sequenznummer werden verworfen.
|
||||||
|
*/
|
||||||
|
private final AtomicLong currentRequestSequence = new AtomicLong(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache bereits gerenderter Seiten für die aktuell geladene Quelldatei.
|
||||||
|
* Schlüssel ist die 1-basierte Seitennummer. Wird beim Wechsel der Quelldatei geleert.
|
||||||
|
*/
|
||||||
|
private final Map<Integer, Image> pageCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Hintergrund-Thread-Pool für Lade- und Rendering-Aufgaben. */
|
||||||
|
private final ExecutorService executor =
|
||||||
|
Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "pdf-preview-worker");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread.
|
||||||
|
* Leerer Referenzwert wenn kein Dokument geöffnet ist.
|
||||||
|
*/
|
||||||
|
private final AtomicReference<PDDocument> currentDocument = new AtomicReference<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread.
|
||||||
|
* Leerer Referenzwert wenn kein Dokument geöffnet ist.
|
||||||
|
*/
|
||||||
|
private final AtomicReference<PDFRenderer> currentRenderer = new AtomicReference<>();
|
||||||
|
|
||||||
|
/** Aktuell geladene Quelldatei; leerer Referenzwert wenn keine Selektion vorliegt. */
|
||||||
|
private final AtomicReference<Path> currentSourceFile = new AtomicReference<>();
|
||||||
|
|
||||||
|
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
|
||||||
|
private volatile int currentPage = 0;
|
||||||
|
|
||||||
|
/** Anzahl der Seiten der aktuell geladenen PDF; -1 wenn nicht ermittelt. */
|
||||||
|
private volatile int totalPages = -1;
|
||||||
|
|
||||||
|
/** Gibt an ob die Navigation bedienbar ist. */
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt die Komponente im deaktivierten Platzhalter-Zustand.
|
||||||
|
*/
|
||||||
|
public PdfPreviewPane() {
|
||||||
|
sectionTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
|
imageView.setId("pdf-preview-image-view");
|
||||||
|
imageView.setPreserveRatio(true);
|
||||||
|
imageView.setSmooth(true);
|
||||||
|
// Fit-to-view: ImageView füllt den verfügbaren Bereich unter Wahrung des Seitenverhältnisses
|
||||||
|
imageView.fitWidthProperty().bind(viewStack.widthProperty());
|
||||||
|
imageView.fitHeightProperty().bind(viewStack.heightProperty());
|
||||||
|
|
||||||
|
overlayLabel.setId("pdf-preview-overlay-label");
|
||||||
|
overlayLabel.setStyle("-fx-text-fill: #555555;");
|
||||||
|
overlayLabel.setWrapText(true);
|
||||||
|
overlayLabel.setVisible(true);
|
||||||
|
overlayLabel.setManaged(true);
|
||||||
|
|
||||||
|
progressIndicator.setId("pdf-preview-progress");
|
||||||
|
progressIndicator.setVisible(false);
|
||||||
|
progressIndicator.setManaged(false);
|
||||||
|
progressIndicator.setMaxWidth(60);
|
||||||
|
progressIndicator.setMaxHeight(60);
|
||||||
|
|
||||||
|
// Stack: ImageView hinter dem Overlay; Overlay überlagert das Bild bei Fehlern/Laden
|
||||||
|
viewStack.getChildren().addAll(imageView, overlayLabel, progressIndicator);
|
||||||
|
StackPane.setAlignment(imageView, Pos.CENTER);
|
||||||
|
StackPane.setAlignment(overlayLabel, Pos.CENTER);
|
||||||
|
StackPane.setAlignment(progressIndicator, Pos.CENTER);
|
||||||
|
|
||||||
|
scrollPane.setId("pdf-preview-scroll-pane");
|
||||||
|
scrollPane.setFitToWidth(true);
|
||||||
|
scrollPane.setFitToHeight(true);
|
||||||
|
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||||
|
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||||
|
// 32c: Verhindert, dass ScrollPane und StackPane beim manuellen Zoom mitwachsen
|
||||||
|
scrollPane.setPrefSize(0, 0);
|
||||||
|
viewStack.setMinSize(0, 0);
|
||||||
|
VBox.setVgrow(scrollPane, Priority.ALWAYS);
|
||||||
|
// Strg + Mausrad → Zoom; ohne Strg → normales Scrollen
|
||||||
|
scrollPane.addEventFilter(ScrollEvent.SCROLL, event -> {
|
||||||
|
if (event.isControlDown()) {
|
||||||
|
accumulateAndApplyZoomDelta(event.getDeltaY());
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Grab & Pan – im manuellen Zoom-Modus mit Maus verschiebbar
|
||||||
|
viewStack.addEventHandler(MouseEvent.MOUSE_PRESSED, this::onPanMousePressed);
|
||||||
|
viewStack.addEventHandler(MouseEvent.MOUSE_DRAGGED, this::onPanMouseDragged);
|
||||||
|
viewStack.addEventHandler(MouseEvent.MOUSE_RELEASED, this::onPanMouseReleased);
|
||||||
|
// viewStack ist immer mindestens so groß wie der Viewport. Ist der Inhalt
|
||||||
|
// (ImageView) kleiner als der Viewport, sorgt diese Mindestgröße zusammen
|
||||||
|
// mit StackPane.Pos.CENTER dafür, dass die ImageView automatisch zentriert
|
||||||
|
// wird – ohne manuelle setHvalue/setVvalue-Eingriffe. Ist der Inhalt größer,
|
||||||
|
// bleibt die Mindestgröße wirkungslos und der ScrollPane scrollt normal.
|
||||||
|
scrollPane.viewportBoundsProperty().addListener((obs, old, bounds) -> {
|
||||||
|
viewStack.setMinWidth(bounds.getWidth());
|
||||||
|
viewStack.setMinHeight(bounds.getHeight());
|
||||||
|
});
|
||||||
|
|
||||||
|
prevButton.setId("pdf-preview-prev-button");
|
||||||
|
prevButton.setOnAction(e -> navigateToPreviousPage());
|
||||||
|
Tooltip prevTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_VORHERIGE_SEITE);
|
||||||
|
prevTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
prevButton.setTooltip(prevTooltip);
|
||||||
|
|
||||||
|
nextButton.setId("pdf-preview-next-button");
|
||||||
|
nextButton.setOnAction(e -> navigateToNextPage());
|
||||||
|
Tooltip nextTooltip = new Tooltip(GuiTooltipTexts.PREVIEW_NAECHSTE_SEITE);
|
||||||
|
nextTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
nextButton.setTooltip(nextTooltip);
|
||||||
|
|
||||||
|
pageLabel.setId("pdf-preview-page-label");
|
||||||
|
pageLabel.setStyle("-fx-text-fill: #555555;");
|
||||||
|
|
||||||
|
HBox navBar = new HBox(8, prevButton, pageLabel, nextButton);
|
||||||
|
navBar.setAlignment(Pos.CENTER);
|
||||||
|
navBar.setPadding(new Insets(4, 0, 4, 0));
|
||||||
|
|
||||||
|
root.getChildren().addAll(sectionTitle, scrollPane, navBar);
|
||||||
|
root.setPadding(new Insets(4, 0, 0, 0));
|
||||||
|
|
||||||
|
showPlaceholder();
|
||||||
|
updateNavigationButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Wurzel-Knoten der Komponente zum Einfügen in den Detailbereich.
|
||||||
|
*
|
||||||
|
* @return das Root-Control; nie null
|
||||||
|
*/
|
||||||
|
public Region getNode() {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die angegebene Quelldatei asynchron und zeigt Seite 1 an.
|
||||||
|
* Startet eine neue Vorschau-Anforderung und verwirft etwaige laufende Anforderungen.
|
||||||
|
* Der Seiten-Cache wird geleert und ein etwaiges bereits geöffnetes PDF-Dokument
|
||||||
|
* wird geschlossen.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param sourceFile Pfad zur Quelldatei; null führt zu {@link #clear()}
|
||||||
|
*/
|
||||||
|
public void loadSource(Path sourceFile) {
|
||||||
|
if (sourceFile == null) {
|
||||||
|
clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentSourceFile.set(sourceFile);
|
||||||
|
currentPage = 0;
|
||||||
|
totalPages = -1;
|
||||||
|
pageCache.clear();
|
||||||
|
resetToFitView();
|
||||||
|
requestLoad(sourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leert die Komponente und zeigt den neutralen Platzhaltertext.
|
||||||
|
* Das aktuell geöffnete PDF-Dokument wird asynchron auf dem Worker-Thread geschlossen.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void clear() {
|
||||||
|
currentSourceFile.set(null);
|
||||||
|
currentPage = 0;
|
||||||
|
totalPages = -1;
|
||||||
|
pageCache.clear();
|
||||||
|
// Neue Sequenznummer: laufende Requests werden verworfen
|
||||||
|
currentRequestSequence.incrementAndGet();
|
||||||
|
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
|
||||||
|
executor.submit(this::closeCurrentDocumentOnWorker);
|
||||||
|
resetToFitView();
|
||||||
|
imageView.setImage(null);
|
||||||
|
showPlaceholder();
|
||||||
|
updateNavigationButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktiviert oder deaktiviert die Navigations-Buttons.
|
||||||
|
* Während eines laufenden Batch-Laufs soll die Navigation deaktiviert sein.
|
||||||
|
* Die Vorschau-Anzeige bleibt sichtbar.
|
||||||
|
*
|
||||||
|
* @param enabled {@code true} wenn Navigation erlaubt ist
|
||||||
|
*/
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
updateNavigationButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet den internen Executor sauber und schließt das eventuell noch offene
|
||||||
|
* PDF-Dokument. Muss beim Schließen der Anwendung aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void shutdown() {
|
||||||
|
try {
|
||||||
|
executor.submit(this::closeCurrentDocumentOnWorker);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Executor wurde bereits beendet – keine Aktion erforderlich
|
||||||
|
}
|
||||||
|
executor.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test-Accessoren ------------------------------------------------------
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Label overlayLabel() {
|
||||||
|
return overlayLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Button prevButton() {
|
||||||
|
return prevButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Button nextButton() {
|
||||||
|
return nextButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
Label pageLabel() {
|
||||||
|
return pageLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
ProgressIndicator progressIndicator() {
|
||||||
|
return progressIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
ScrollPane scrollPane() {
|
||||||
|
return scrollPane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
double zoomLevel() {
|
||||||
|
return zoomLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation -----------------------------------------------------------
|
||||||
|
|
||||||
|
private void navigateToPreviousPage() {
|
||||||
|
if (!enabled || currentPage <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goToPage(currentPage - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void navigateToNextPage() {
|
||||||
|
if (!enabled || totalPages <= 0 || currentPage >= totalPages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goToPage(currentPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wechselt zur angegebenen Seite. Bereits gerenderte Seiten werden direkt aus dem
|
||||||
|
* Cache angezeigt; ansonsten wird ein Rendering-Auftrag auf den Worker-Thread gelegt.
|
||||||
|
*
|
||||||
|
* @param targetPage Ziel-Seite (1-basiert, muss im gültigen Bereich liegen)
|
||||||
|
*/
|
||||||
|
private void goToPage(int targetPage) {
|
||||||
|
currentPage = targetPage;
|
||||||
|
updatePageLabel();
|
||||||
|
updateNavigationButtons();
|
||||||
|
|
||||||
|
Image cached = pageCache.get(targetPage);
|
||||||
|
if (cached != null) {
|
||||||
|
imageView.setImage(cached);
|
||||||
|
showContent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long seq = currentRequestSequence.incrementAndGet();
|
||||||
|
showLoading();
|
||||||
|
executor.submit(() -> renderPageOnWorker(targetPage, seq));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Asynchrones Laden und Rendering --------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet eine asynchrone Lade-Anforderung für die angegebene Datei.
|
||||||
|
* Erhöht die Sequenznummer, damit veraltete Ergebnisse erkannt und verworfen werden.
|
||||||
|
*
|
||||||
|
* @param file die zu ladende Quelldatei
|
||||||
|
*/
|
||||||
|
private void requestLoad(Path file) {
|
||||||
|
long seq = currentRequestSequence.incrementAndGet();
|
||||||
|
LOG.debug("PDF-Vorschau: Lade {} (Anforderung #{})", file, seq);
|
||||||
|
|
||||||
|
showLoading();
|
||||||
|
updateNavigationButtons();
|
||||||
|
|
||||||
|
executor.submit(() -> loadAndRenderFirstPageOnWorker(file, seq));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet die PDF-Datei, ermittelt die Seitenzahl und rendert die erste Seite.
|
||||||
|
* Läuft ausschließlich auf dem Worker-Thread.
|
||||||
|
*
|
||||||
|
* @param file die zu ladende Datei
|
||||||
|
* @param seq die Sequenznummer dieser Anforderung
|
||||||
|
*/
|
||||||
|
private void loadAndRenderFirstPageOnWorker(Path file, long seq) {
|
||||||
|
File ioFile = file.toFile();
|
||||||
|
|
||||||
|
if (!ioFile.exists()) {
|
||||||
|
LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen – Datei nicht gefunden: {}", file);
|
||||||
|
publishError(seq, FILE_NOT_FOUND_TEXT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorheriges Dokument schließen bevor ein neues geöffnet wird
|
||||||
|
closeCurrentDocumentOnWorker();
|
||||||
|
|
||||||
|
try {
|
||||||
|
PDDocument doc = Loader.loadPDF(ioFile);
|
||||||
|
currentDocument.set(doc);
|
||||||
|
PDFRenderer renderer = new PDFRenderer(doc);
|
||||||
|
currentRenderer.set(renderer);
|
||||||
|
|
||||||
|
int pages = Math.max(1, doc.getNumberOfPages());
|
||||||
|
BufferedImage buffered =
|
||||||
|
renderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
|
||||||
|
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
|
||||||
|
|
||||||
|
final int totalPagesFinal = pages;
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (currentRequestSequence.get() != seq) {
|
||||||
|
return; // Veraltet – verwerfen
|
||||||
|
}
|
||||||
|
totalPages = totalPagesFinal;
|
||||||
|
currentPage = 1;
|
||||||
|
pageCache.put(1, fxImage);
|
||||||
|
imageView.setImage(fxImage);
|
||||||
|
showContent();
|
||||||
|
updateNavigationButtons();
|
||||||
|
updatePageLabel();
|
||||||
|
LOG.debug("PDF-Vorschau: Rendering abgeschlossen – {} Seite(n)", totalPagesFinal);
|
||||||
|
});
|
||||||
|
} catch (InvalidPasswordException ipe) {
|
||||||
|
LOG.warn("PDF-Vorschau: PDF ist passwortgeschützt: {}", file, ipe);
|
||||||
|
closeCurrentDocumentOnWorker();
|
||||||
|
publishError(seq, PDF_PASSWORD_PROTECTED_TEXT);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warn("PDF-Vorschau: Rendering fehlgeschlagen: {}", file, e);
|
||||||
|
closeCurrentDocumentOnWorker();
|
||||||
|
publishError(seq, PDF_UNREADABLE_TEXT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert eine einzelne Seite des aktuell geöffneten Dokuments.
|
||||||
|
* Läuft ausschließlich auf dem Worker-Thread.
|
||||||
|
*
|
||||||
|
* @param page 1-basierte Seitennummer
|
||||||
|
* @param seq die Sequenznummer dieser Anforderung
|
||||||
|
*/
|
||||||
|
private void renderPageOnWorker(int page, long seq) {
|
||||||
|
PDFRenderer renderer = currentRenderer.get();
|
||||||
|
if (renderer == null) {
|
||||||
|
// Dokument wurde zwischenzeitlich geschlossen – nichts zu tun
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
BufferedImage buffered = renderer.renderImageWithDPI(page - 1, RENDER_DPI, ImageType.RGB);
|
||||||
|
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (currentRequestSequence.get() != seq) {
|
||||||
|
return; // Veraltet – verwerfen
|
||||||
|
}
|
||||||
|
pageCache.put(page, fxImage);
|
||||||
|
if (currentPage == page) {
|
||||||
|
imageView.setImage(fxImage);
|
||||||
|
showContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warn("PDF-Vorschau: Rendering von Seite {} fehlgeschlagen", page, e);
|
||||||
|
publishError(seq, PDF_UNREADABLE_TEXT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schließt das aktuell geöffnete PDF-Dokument, falls vorhanden. Läuft ausschließlich
|
||||||
|
* auf dem Worker-Thread und ist idempotent.
|
||||||
|
*/
|
||||||
|
private void closeCurrentDocumentOnWorker() {
|
||||||
|
PDDocument doc = currentDocument.getAndSet(null);
|
||||||
|
currentRenderer.set(null);
|
||||||
|
if (doc != null) {
|
||||||
|
try {
|
||||||
|
doc.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.debug("PDF-Vorschau: Schließen des Dokuments schlug fehl", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übergibt eine Fehlermeldung auf den FX-Thread. Veraltete Meldungen werden verworfen.
|
||||||
|
*
|
||||||
|
* @param seq Sequenznummer der Anforderung, zu der die Meldung gehört
|
||||||
|
* @param message anzuzeigende Fehlermeldung
|
||||||
|
*/
|
||||||
|
private void publishError(long seq, String message) {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
if (currentRequestSequence.get() != seq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError(message);
|
||||||
|
updateNavigationButtons();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grab & Pan -----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet die Pan-Geste. Speichert die Startposition und den aktuellen Scroll-Zustand.
|
||||||
|
* Nur aktiv wenn der manuelle Zoom-Modus eingeschaltet ist.
|
||||||
|
*
|
||||||
|
* @param event das Maus-Pressed-Ereignis
|
||||||
|
*/
|
||||||
|
private void onPanMousePressed(MouseEvent event) {
|
||||||
|
if (scrollPane.isFitToWidth()) {
|
||||||
|
return; // Im Fit-Modus kein Pan nötig
|
||||||
|
}
|
||||||
|
panStartX = event.getScreenX();
|
||||||
|
panStartY = event.getScreenY();
|
||||||
|
panStartHvalue = scrollPane.getHvalue();
|
||||||
|
panStartVvalue = scrollPane.getVvalue();
|
||||||
|
viewStack.setCursor(Cursor.CLOSED_HAND);
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt den Viewport relativ zur Startposition der Pan-Geste.
|
||||||
|
* Die Scrolldelta wird auf die scrollbaren Bereiche des Inhalts normiert.
|
||||||
|
*
|
||||||
|
* @param event das Maus-Dragged-Ereignis
|
||||||
|
*/
|
||||||
|
private void onPanMouseDragged(MouseEvent event) {
|
||||||
|
if (panStartX < 0 || scrollPane.isFitToWidth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double dx = event.getScreenX() - panStartX;
|
||||||
|
double dy = event.getScreenY() - panStartY;
|
||||||
|
|
||||||
|
Bounds viewport = scrollPane.getViewportBounds();
|
||||||
|
double contentWidth = viewStack.getWidth();
|
||||||
|
double contentHeight = viewStack.getHeight();
|
||||||
|
double viewportWidth = viewport != null ? viewport.getWidth() : 0;
|
||||||
|
double viewportHeight = viewport != null ? viewport.getHeight() : 0;
|
||||||
|
|
||||||
|
double scrollableWidth = contentWidth - viewportWidth;
|
||||||
|
double scrollableHeight = contentHeight - viewportHeight;
|
||||||
|
|
||||||
|
if (scrollableWidth > 0) {
|
||||||
|
double newHval = panStartHvalue - dx / scrollableWidth;
|
||||||
|
scrollPane.setHvalue(Math.max(0, Math.min(1, newHval)));
|
||||||
|
}
|
||||||
|
if (scrollableHeight > 0) {
|
||||||
|
double newVval = panStartVvalue - dy / scrollableHeight;
|
||||||
|
scrollPane.setVvalue(Math.max(0, Math.min(1, newVval)));
|
||||||
|
}
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet die Pan-Geste und stellt den OPEN_HAND-Mauszeiger wieder her.
|
||||||
|
*
|
||||||
|
* @param event das Maus-Released-Ereignis
|
||||||
|
*/
|
||||||
|
private void onPanMouseReleased(MouseEvent event) {
|
||||||
|
panStartX = -1;
|
||||||
|
panStartY = -1;
|
||||||
|
if (!scrollPane.isFitToWidth()) {
|
||||||
|
viewStack.setCursor(Cursor.OPEN_HAND);
|
||||||
|
}
|
||||||
|
event.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zoom -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Akkumuliert den Scroll-Delta und wendet den Zoom schrittweise an.
|
||||||
|
* Pro Raste (ca. {@value #ZOOM_NOTCH_THRESHOLD} Einheiten) ändert sich der Zoom
|
||||||
|
* um {@value #ZOOM_STEP}. Pro ScrollEvent wird maximal eine Zoom-Stufe angewendet.
|
||||||
|
*
|
||||||
|
* <p>Der Rohwert von {@code deltaY} wird vor der Akkumulation auf einen
|
||||||
|
* Notch-Wert ({@value #ZOOM_NOTCH_THRESHOLD}) begrenzt. Plattformspezifische
|
||||||
|
* Scroll-Multiplikatoren (z. B. Windows-Mausgeschwindigkeit, hohe DPI-Mäuse)
|
||||||
|
* können sonst Werte wie 120 oder mehr pro Raste liefern, was einen
|
||||||
|
* Akkumulator-Überlauf in Folge-Events verursacht.
|
||||||
|
*
|
||||||
|
* @param deltaY vertikaler Scroll-Delta des {@link ScrollEvent}
|
||||||
|
*/
|
||||||
|
private void accumulateAndApplyZoomDelta(double deltaY) {
|
||||||
|
// Normierung: maximal einen Notch-Wert pro Event akkumulieren, um
|
||||||
|
// plattformspezifische deltaY-Überhöhungen (z. B. 120 statt 40) abzufangen
|
||||||
|
double capped = Math.signum(deltaY) * Math.min(Math.abs(deltaY), ZOOM_NOTCH_THRESHOLD);
|
||||||
|
zoomAccumulator += capped;
|
||||||
|
if (zoomAccumulator >= ZOOM_NOTCH_THRESHOLD) {
|
||||||
|
zoomAccumulator -= ZOOM_NOTCH_THRESHOLD;
|
||||||
|
applyZoom(Math.min(ZOOM_MAX, zoomLevel + ZOOM_STEP));
|
||||||
|
} else if (zoomAccumulator <= -ZOOM_NOTCH_THRESHOLD) {
|
||||||
|
zoomAccumulator += ZOOM_NOTCH_THRESHOLD;
|
||||||
|
applyZoom(Math.max(ZOOM_MIN, zoomLevel - ZOOM_STEP));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Zoomfaktor und verlässt beim ersten Aufruf den Fit-to-View-Modus.
|
||||||
|
* <p>
|
||||||
|
* Beim ersten Aufruf (Wechsel aus dem Fit-Modus) wird {@code zoomLevel} auf
|
||||||
|
* den aktuellen visuellen Skalierungsfaktor kalibriert: aktuelle visuelle
|
||||||
|
* Breite der ImageView (mit {@code preserveRatio} bereits aspekt-korrekt
|
||||||
|
* verkleinert) geteilt durch die natürliche Bildbreite. Damit entspricht
|
||||||
|
* {@code zoomLevel = 1.0} der pixel-genauen Originalgröße, und der erste
|
||||||
|
* Zoom-Schritt addiert sich auf den realen Skalierungsfaktor. Ohne diese
|
||||||
|
* Kalibrierung springt die ImageView abrupt auf {@code Viewport-Breite × 1.10},
|
||||||
|
* weil im Fit-Modus die {@code fitHeight}-Bindung das Bild aspekt-erhaltend
|
||||||
|
* deutlich kleiner zwingt als {@code naturalViewportWidth × 1.0} ergibt.
|
||||||
|
* Da der Caller den Delta-Schritt auf dem alten {@code zoomLevel = 1.0}
|
||||||
|
* berechnet hat, wird er nach der Kalibrierung auf den neuen, kalibrierten
|
||||||
|
* {@code zoomLevel} re-appliziert.
|
||||||
|
* <p>
|
||||||
|
* Beim Wechsel aus dem Fit-to-View-Modus wird die Ansicht auf die Bildmitte
|
||||||
|
* zentriert (H/V = 0.5). Bei weiteren Zoom-Schritten bleibt die aktuelle
|
||||||
|
* Scrollposition erhalten. Ein {@code layout()}-Aufruf vor der
|
||||||
|
* Positionswiederherstellung stellt sicher, dass die neuen Inhaltsgrenzen
|
||||||
|
* bereits berechnet sind.
|
||||||
|
*
|
||||||
|
* @param newZoom gewünschter Zoomfaktor, wird auf [{@link #ZOOM_MIN}, {@link #ZOOM_MAX}] begrenzt
|
||||||
|
*/
|
||||||
|
private void applyZoom(double newZoom) {
|
||||||
|
double effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom));
|
||||||
|
|
||||||
|
boolean wasInFitMode = scrollPane.isFitToWidth();
|
||||||
|
if (wasInFitMode) {
|
||||||
|
Image image = imageView.getImage();
|
||||||
|
if (image == null || image.getWidth() <= 0) {
|
||||||
|
return; // Kein Bild – Zoom-Kalibrierung nicht möglich
|
||||||
|
}
|
||||||
|
double naturalImageWidth = image.getWidth();
|
||||||
|
double currentVisualWidth = imageView.getBoundsInLocal().getWidth();
|
||||||
|
if (currentVisualWidth <= 0) {
|
||||||
|
Bounds viewport = scrollPane.getViewportBounds();
|
||||||
|
currentVisualWidth = viewport != null ? viewport.getWidth() : viewStack.getWidth();
|
||||||
|
if (currentVisualWidth <= 0) {
|
||||||
|
return; // Layout noch nicht abgeschlossen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vom Caller intendierten Delta-Schritt vor der Kalibrierung sichern
|
||||||
|
double requestedDelta = newZoom - zoomLevel;
|
||||||
|
|
||||||
|
// zoomLevel auf den aktuellen visuellen Skalierungsfaktor kalibrieren
|
||||||
|
naturalViewportWidth = naturalImageWidth;
|
||||||
|
zoomLevel = currentVisualWidth / naturalImageWidth;
|
||||||
|
|
||||||
|
// effective neu berechnen, weil zoomLevel sich geändert hat
|
||||||
|
effective = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoomLevel + requestedDelta));
|
||||||
|
|
||||||
|
scrollPane.setFitToWidth(false);
|
||||||
|
scrollPane.setFitToHeight(false);
|
||||||
|
imageView.fitWidthProperty().unbind();
|
||||||
|
imageView.fitHeightProperty().unbind();
|
||||||
|
// Mauszeiger signalisiert Pan-Modus
|
||||||
|
viewStack.setCursor(Cursor.OPEN_HAND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effective == zoomLevel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomLevel = effective;
|
||||||
|
imageView.setFitWidth(naturalViewportWidth * zoomLevel);
|
||||||
|
imageView.setFitHeight(0);
|
||||||
|
// Keine manuellen setHvalue/setVvalue-Eingriffe nötig: viewStack hat
|
||||||
|
// dank des viewportBoundsProperty-Listeners im Konstruktor mindestens
|
||||||
|
// Viewport-Größe, und Pos.CENTER sorgt für automatische Zentrierung,
|
||||||
|
// wenn der Inhalt kleiner als der Viewport ist.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt Zoom, Akkumulator und Pan-Zustand zurück und reaktiviert den Fit-to-View-Modus.
|
||||||
|
* Wird beim Laden einer neuen Datei und beim Leeren der Komponente aufgerufen.
|
||||||
|
*
|
||||||
|
* <p>Reihenfolge der Aktionen ist kritisch:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code setFitToWidth(true)} und {@code setFitToHeight(true)} sofort,
|
||||||
|
* damit der nächste Layout-Pass den {@code viewStack} auf Viewport-Größe
|
||||||
|
* zurückrechnet.</li>
|
||||||
|
* <li>Property-Bindungen und H/V-Reset im {@code Platform.runLater}, damit
|
||||||
|
* sie auf die bereits zurückgerechneten {@code viewStack}-Dimensionen
|
||||||
|
* wirken und nicht auf die noch zoom-große Breite.</li>
|
||||||
|
* </ol>
|
||||||
|
* Ohne diese Reihenfolge würden die Bindungen die imageView kurz an die
|
||||||
|
* Zoom-Größe koppeln, und ein verbleibender H/V-Wert aus dem Pan-/Zoom-Modus
|
||||||
|
* (z. B. {@code hvalue=0.0} nach Pan zum linken Rand) würde die PDF wegen
|
||||||
|
* kleinster Rounding-/Border-Differenzen links/oben bündig statt zentriert
|
||||||
|
* anzeigen, obwohl der ScrollPane fit-aktiv ist.
|
||||||
|
*/
|
||||||
|
private void resetToFitView() {
|
||||||
|
zoomLevel = 1.0;
|
||||||
|
zoomAccumulator = 0.0;
|
||||||
|
naturalViewportWidth = 0.0;
|
||||||
|
// Pan-Zustand und Mauszeiger zurücksetzen
|
||||||
|
panStartX = -1;
|
||||||
|
panStartY = -1;
|
||||||
|
viewStack.setCursor(null);
|
||||||
|
if (!scrollPane.isFitToWidth()) {
|
||||||
|
// 1. ScrollPane in Fit-Modus schalten, damit der nächste Layout-Pass
|
||||||
|
// den viewStack auf Viewport-Größe zurückrechnet
|
||||||
|
scrollPane.setFitToWidth(true);
|
||||||
|
scrollPane.setFitToHeight(true);
|
||||||
|
// 2. Bindings erst nach abgeschlossenem Layout-Pass, damit sie auf
|
||||||
|
// die zurückgerechneten viewStack-Dimensionen wirken
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
imageView.fitWidthProperty().bind(viewStack.widthProperty());
|
||||||
|
imageView.fitHeightProperty().bind(viewStack.heightProperty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI-Zustandshelfer ---------------------------------------------------
|
||||||
|
|
||||||
|
private void showPlaceholder() {
|
||||||
|
overlayLabel.setText(PLACEHOLDER_TEXT);
|
||||||
|
overlayLabel.setVisible(true);
|
||||||
|
overlayLabel.setManaged(true);
|
||||||
|
imageView.setVisible(false);
|
||||||
|
imageView.setManaged(false);
|
||||||
|
progressIndicator.setVisible(false);
|
||||||
|
progressIndicator.setManaged(false);
|
||||||
|
pageLabel.setText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLoading() {
|
||||||
|
progressIndicator.setVisible(true);
|
||||||
|
progressIndicator.setManaged(true);
|
||||||
|
overlayLabel.setVisible(false);
|
||||||
|
overlayLabel.setManaged(false);
|
||||||
|
imageView.setVisible(false);
|
||||||
|
imageView.setManaged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showContent() {
|
||||||
|
progressIndicator.setVisible(false);
|
||||||
|
progressIndicator.setManaged(false);
|
||||||
|
overlayLabel.setVisible(false);
|
||||||
|
overlayLabel.setManaged(false);
|
||||||
|
imageView.setVisible(true);
|
||||||
|
imageView.setManaged(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showError(String message) {
|
||||||
|
overlayLabel.setText(message);
|
||||||
|
overlayLabel.setVisible(true);
|
||||||
|
overlayLabel.setManaged(true);
|
||||||
|
imageView.setVisible(false);
|
||||||
|
imageView.setManaged(false);
|
||||||
|
progressIndicator.setVisible(false);
|
||||||
|
progressIndicator.setManaged(false);
|
||||||
|
pageLabel.setText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateNavigationButtons() {
|
||||||
|
boolean canNavigate = enabled && currentSourceFile.get() != null && totalPages > 0;
|
||||||
|
prevButton.setDisable(!canNavigate || currentPage <= 1);
|
||||||
|
nextButton.setDisable(!canNavigate || currentPage >= totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updatePageLabel() {
|
||||||
|
if (totalPages > 0 && currentPage > 0) {
|
||||||
|
pageLabel.setText("Seite " + currentPage + " / " + totalPages);
|
||||||
|
} else {
|
||||||
|
pageLabel.setText("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+287
@@ -0,0 +1,287 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Mapping-Klasse für die visuelle Darstellung von Verarbeitungsstatus in der GUI.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse ist die einzige autoritative Quelle für Status-Icons, CSS-Farben,
|
||||||
|
* Tooltip-Texte und Summary-Kategorielabels aller {@link DocumentCompletionStatus}-Werte.
|
||||||
|
* Alle Anzeigeorte im GUI-Adapter (Ergebnistabelle, Detailbereich, Summary-Banner)
|
||||||
|
* beziehen ihre Darstellungsinformationen ausschließlich über diese Klasse.
|
||||||
|
* <p>
|
||||||
|
* Farbe ist niemals das einzige Unterscheidungsmerkmal: Icon und Tooltip-Text beschreiben
|
||||||
|
* den Status vollständig auch ohne Farbwahrnehmung.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse enthält keine JavaFX-Typen; sie ist rein datenhaltend und zustandslos.
|
||||||
|
* Alle Methoden sind statisch.
|
||||||
|
*/
|
||||||
|
public final class ProcessingStatusPresentation {
|
||||||
|
private static final String STATUS_NOT_NULL = "status darf nicht null sein";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Icons (Unicode-Zeichen, zuverlässig darstellbar unter Windows 10+)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||||
|
public static final String ICON_SUCCESS = "✓"; // CHECK MARK
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||||
|
public static final String ICON_FAILED_RETRYABLE = "↻"; // CLOCKWISE OPEN CIRCLE ARROW
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||||
|
public static final String ICON_FAILED_PERMANENT = "×"; // MULTIPLICATION SIGN
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||||
|
public static final String ICON_SKIPPED_ALREADY_PROCESSED = "≡"; // IDENTICAL TO
|
||||||
|
|
||||||
|
/** Icon für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||||
|
public static final String ICON_SKIPPED_FINAL_FAILURE = "⊘"; // CIRCLED DIVISION SLASH
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CSS-Farben (Hex-Strings für JavaFX setStyle)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||||
|
public static final String COLOR_SUCCESS = "#2e7d32"; // Grün
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||||
|
public static final String COLOR_FAILED_RETRYABLE = "#d98200"; // Orange
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||||
|
public static final String COLOR_FAILED_PERMANENT = "#c62828"; // Rot
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||||
|
public static final String COLOR_SKIPPED_ALREADY_PROCESSED = "#757575"; // Grau
|
||||||
|
|
||||||
|
/** CSS-Farbe für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||||
|
public static final String COLOR_SKIPPED_FINAL_FAILURE = "#424242"; // Dunkelgrau
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Tooltip-Texte (deutsche Benutzertexte, gemäß Spezifikation)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||||
|
public static final String TOOLTIP_SUCCESS =
|
||||||
|
"Erfolgreich verarbeitet und umbenannt.";
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||||
|
public static final String TOOLTIP_FAILED_RETRYABLE =
|
||||||
|
"Temporärer Fehler – wird beim nächsten Lauf automatisch erneut versucht.";
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||||
|
public static final String TOOLTIP_FAILED_PERMANENT =
|
||||||
|
"Dauerhaft nicht verarbeitbar – z. B. kein Textinhalt (Foto-PDF), Passwortschutz "
|
||||||
|
+ "oder beschädigte Datei. Kein weiterer automatischer Versuch.";
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||||
|
public static final String TOOLTIP_SKIPPED_ALREADY_PROCESSED =
|
||||||
|
"Übersprungen – wurde bereits in einem früheren Lauf erfolgreich verarbeitet.";
|
||||||
|
|
||||||
|
/** Tooltip für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||||
|
public static final String TOOLTIP_SKIPPED_FINAL_FAILURE =
|
||||||
|
"Endgültig übersprungen nach wiederholten Fehlern.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Detailtext für FAILED_PERMANENT (Erklärung im Detailbereich)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erweiterter Erklärungstext, der im Detailbereich bei dauerhaft fehlgeschlagenen
|
||||||
|
* Dokumenten angezeigt wird.
|
||||||
|
*/
|
||||||
|
public static final String DETAIL_TEXT_FAILED_PERMANENT =
|
||||||
|
"Diese Datei kann nicht verarbeitet werden. Mögliche Ursachen: "
|
||||||
|
+ "kein lesbarer Text (z. B. gescanntes Foto ohne OCR), Passwortschutz "
|
||||||
|
+ "oder beschädigte Datei. "
|
||||||
|
+ "Sie können den Status manuell zurücksetzen, wenn Sie die Ursache behoben haben.";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Summary-Kategorielabels
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#SUCCESS}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_SUCCESS = "erfolgreich";
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_RETRYABLE}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_FAILED_RETRYABLE = "wird wiederholt";
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#FAILED_PERMANENT}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_FAILED_PERMANENT = "fehlgeschlagen";
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_ALREADY_PROCESSED}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED = "übersprungen";
|
||||||
|
|
||||||
|
/** Summary-Kategorie für {@link DocumentCompletionStatus#SKIPPED_FINAL_FAILURE}. */
|
||||||
|
public static final String SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE = "endgültig übersprungen";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Record-Typ für gebündelte Darstellungsinformationen
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gebündelte visuelle Darstellungsinformationen für einen Verarbeitungsstatus.
|
||||||
|
*
|
||||||
|
* @param icon Unicode-Zeichen als Status-Icon; nie leer
|
||||||
|
* @param cssColor CSS-Hex-Farbe für das Icon, z. B. {@code "#2e7d32"}; nie leer
|
||||||
|
* @param tooltipText Deutschsprachiger Tooltip-Text; nie leer
|
||||||
|
* @param summaryCategoryLabel Kategorie-Bezeichnung für das Summary-Banner; nie leer
|
||||||
|
*/
|
||||||
|
public record StatusVisuals(
|
||||||
|
String icon,
|
||||||
|
String cssColor,
|
||||||
|
String tooltipText,
|
||||||
|
String summaryCategoryLabel) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakter Konstruktor zur Pflichtfeld-Validierung.
|
||||||
|
*
|
||||||
|
* @throws NullPointerException wenn ein Feld {@code null} ist
|
||||||
|
* @throws IllegalArgumentException wenn ein String-Feld leer ist
|
||||||
|
*/
|
||||||
|
public StatusVisuals {
|
||||||
|
Objects.requireNonNull(icon, "icon muss gesetzt sein");
|
||||||
|
Objects.requireNonNull(cssColor, "cssColor muss gesetzt sein");
|
||||||
|
Objects.requireNonNull(tooltipText, "tooltipText muss gesetzt sein");
|
||||||
|
Objects.requireNonNull(summaryCategoryLabel, "summaryCategoryLabel muss gesetzt sein");
|
||||||
|
if (icon.isBlank()) throw new IllegalArgumentException("icon darf nicht leer sein");
|
||||||
|
if (cssColor.isBlank()) throw new IllegalArgumentException("cssColor darf nicht leer sein");
|
||||||
|
if (tooltipText.isBlank()) throw new IllegalArgumentException("tooltipText darf nicht leer sein");
|
||||||
|
if (summaryCategoryLabel.isBlank())
|
||||||
|
throw new IllegalArgumentException("summaryCategoryLabel darf nicht leer sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Zentrale Mapping-Methoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert das Status-Icon für den angegebenen Verarbeitungsstatus.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return das zugehörige Unicode-Zeichen; nie leer
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static String iconFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> ICON_SUCCESS;
|
||||||
|
case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE;
|
||||||
|
case FAILED_PERMANENT -> ICON_FAILED_PERMANENT;
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> ICON_SKIPPED_ALREADY_PROCESSED;
|
||||||
|
case SKIPPED_FINAL_FAILURE -> ICON_SKIPPED_FINAL_FAILURE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die CSS-Hex-Farbe für das Status-Icon des angegebenen Verarbeitungsstatus.
|
||||||
|
* <p>
|
||||||
|
* Die Farbe ist nie das einzige Unterscheidungsmerkmal – Icon und Tooltip-Text
|
||||||
|
* beschreiben den Status unabhängig von der Farbe eindeutig.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return die CSS-Hex-Farbe (z. B. {@code "#2e7d32"}); nie leer
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static String cssColorFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> COLOR_SUCCESS;
|
||||||
|
case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE;
|
||||||
|
case FAILED_PERMANENT -> COLOR_FAILED_PERMANENT;
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> COLOR_SKIPPED_ALREADY_PROCESSED;
|
||||||
|
case SKIPPED_FINAL_FAILURE -> COLOR_SKIPPED_FINAL_FAILURE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den deutschsprachigen Tooltip-Text für den angegebenen Verarbeitungsstatus.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return der Tooltip-Text; nie leer
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static String tooltipFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> TOOLTIP_SUCCESS;
|
||||||
|
case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE;
|
||||||
|
case FAILED_PERMANENT -> TOOLTIP_FAILED_PERMANENT;
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> TOOLTIP_SKIPPED_ALREADY_PROCESSED;
|
||||||
|
case SKIPPED_FINAL_FAILURE -> TOOLTIP_SKIPPED_FINAL_FAILURE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Summary-Kategorie-Bezeichnung für den angegebenen Verarbeitungsstatus.
|
||||||
|
* Diese Kategorie wird im Summary-Banner nach einem Lauf angezeigt.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return die Kategorienbezeichnung; nie leer
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static String summaryCategoryFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> SUMMARY_CATEGORY_SUCCESS;
|
||||||
|
case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE;
|
||||||
|
case FAILED_PERMANENT -> SUMMARY_CATEGORY_FAILED_PERMANENT;
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> SUMMARY_CATEGORY_SKIPPED_ALREADY_PROCESSED;
|
||||||
|
case SKIPPED_FINAL_FAILURE -> SUMMARY_CATEGORY_SKIPPED_FINAL_FAILURE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle gebündelten visuellen Darstellungsinformationen für den angegebenen
|
||||||
|
* Verarbeitungsstatus in einem einzigen Objekt.
|
||||||
|
*
|
||||||
|
* @param status der Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return ein befülltes {@link StatusVisuals}-Record; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static StatusVisuals visualsFor(DocumentCompletionStatus status) {
|
||||||
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
|
return new StatusVisuals(
|
||||||
|
iconFor(status),
|
||||||
|
cssColorFor(status),
|
||||||
|
tooltipFor(status),
|
||||||
|
summaryCategoryFor(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Mapping für ProcessingStatus (alle acht Domain-Statuswerte)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den deutschsprachigen Anzeigetext mit Icon für den angegebenen
|
||||||
|
* Domain-Verarbeitungsstatus. Kein Enum-Rohname darf für Endnutzer sichtbar sein.
|
||||||
|
*
|
||||||
|
* @param status der Domain-Verarbeitungsstatus; darf nicht {@code null} sein
|
||||||
|
* @return der Anzeigetext mit vorangestelltem Icon; nie leer
|
||||||
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static String displayTextFor(ProcessingStatus status) {
|
||||||
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
|
return switch (status) {
|
||||||
|
case SUCCESS -> "✓ Erfolgreich";
|
||||||
|
case FAILED_RETRYABLE -> "↻ Temporärer Fehler";
|
||||||
|
case FAILED_FINAL -> "× Dauerhaft fehlgeschlagen";
|
||||||
|
case SKIPPED_ALREADY_PROCESSED -> "≡ Bereits verarbeitet";
|
||||||
|
case SKIPPED_FINAL_FAILURE -> "⊘ Endgültig übersprungen";
|
||||||
|
case READY_FOR_AI -> "⟳ Wartet auf Verarbeitung";
|
||||||
|
case PROPOSAL_READY -> "◇ Vorschlag vorhanden";
|
||||||
|
case PROCESSING -> "▶ In Bearbeitung";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nicht instanziierbar – reine Utility-Klasse. */
|
||||||
|
private ProcessingStatusPresentation() {
|
||||||
|
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Inbound adapter components that drive the GUI's processing-run tab.
|
||||||
|
* <p>
|
||||||
|
* The classes in this package build the second tab of the main window, translate
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver}
|
||||||
|
* callbacks into JavaFX UI updates, and manage the worker thread that executes a
|
||||||
|
* single run against a stored {@code .properties} configuration.
|
||||||
|
*
|
||||||
|
* <h2>Threading contract</h2>
|
||||||
|
* <p>
|
||||||
|
* The batch run itself always executes on a dedicated background worker thread obtained
|
||||||
|
* from {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunCoordinator}.
|
||||||
|
* Every UI mutation (progress bar value, result rows, button states, tab sperre) is
|
||||||
|
* dispatched onto the JavaFX Application Thread via {@code Platform.runLater}. No class
|
||||||
|
* in this package mutates a JavaFX {@code Control} from the worker thread.
|
||||||
|
*
|
||||||
|
* <h2>Cancellation</h2>
|
||||||
|
* <p>
|
||||||
|
* The coordinator exposes a soft-stop cancellation hook: setting the cancellation flag
|
||||||
|
* causes the use case to stop <em>before</em> starting the next candidate; the candidate
|
||||||
|
* currently being processed is always completed in full so the SQLite persistence remains
|
||||||
|
* consistent.
|
||||||
|
*
|
||||||
|
* <h2>Configuration source</h2>
|
||||||
|
* <p>
|
||||||
|
* A run is always started against the {@code .properties} file currently on disk (the
|
||||||
|
* last saved state of the editor). Unsaved editor content is intentionally not forwarded
|
||||||
|
* to the launcher — the run must match what a parallel headless launch would see.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import javafx.util.StringConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A JavaFX {@link StringConverter} that maps {@link AiProviderFamily} constants to
|
||||||
|
* German display labels and back.
|
||||||
|
* <p>
|
||||||
|
* Used by the provider selection {@code ComboBox} to show human-readable German names
|
||||||
|
* while keeping the underlying model type-safe. The reverse conversion
|
||||||
|
* ({@link #fromString(String)}) supports the same label strings produced by
|
||||||
|
* {@link #toString(AiProviderFamily)} so that a ComboBox configured as non-editable
|
||||||
|
* can still convert its selected text back to the enum constant when needed.
|
||||||
|
* <p>
|
||||||
|
* Returns {@code null} for inputs that do not match any known constant to signal an
|
||||||
|
* unrecognised display label.
|
||||||
|
*/
|
||||||
|
public final class AiProviderFamilyStringConverter extends StringConverter<AiProviderFamily> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new converter instance.
|
||||||
|
*/
|
||||||
|
public AiProviderFamilyStringConverter() {
|
||||||
|
// Default constructor.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the German display label for the given provider family.
|
||||||
|
*
|
||||||
|
* @param family the provider family to convert; may be {@code null}
|
||||||
|
* @return the German display label, or an empty string when {@code family} is {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String toString(AiProviderFamily family) {
|
||||||
|
if (family == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return switch (family) {
|
||||||
|
case CLAUDE -> "Claude";
|
||||||
|
case OPENAI_COMPATIBLE -> "OpenAI-kompatibel";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a German display label back to its {@link AiProviderFamily} constant.
|
||||||
|
*
|
||||||
|
* @param label the display label as produced by {@link #toString(AiProviderFamily)};
|
||||||
|
* may be {@code null}
|
||||||
|
* @return the matching constant, or {@code null} when the label is not recognised
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public AiProviderFamily fromString(String label) {
|
||||||
|
if (label == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (label) {
|
||||||
|
case "Claude" -> AiProviderFamily.CLAUDE;
|
||||||
|
case "OpenAI-kompatibel" -> AiProviderFamily.OPENAI_COMPATIBLE;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionPlan;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inhalt des gesammelten Bestätigungsdialogs für schreibende Korrekturmaßnahmen.
|
||||||
|
* <p>
|
||||||
|
* Bevor schreibende Korrekturen aus einem {@link CorrectionPlan} ausgeführt werden,
|
||||||
|
* zeigt die GUI diesen Inhalt in einem einmaligen Bestätigungsdialog. Der Benutzer
|
||||||
|
* kann die Korrekturen bestätigen oder ablehnen; ohne Bestätigung werden keine
|
||||||
|
* Änderungen vorgenommen.
|
||||||
|
* <p>
|
||||||
|
* Dieser Record liegt bewusst im GUI-Modul, da er ausschließlich für die
|
||||||
|
* Darstellung im Bestätigungsdialog der JavaFX-Oberfläche genutzt wird. Er enthält
|
||||||
|
* selbst keine JavaFX-Typen und kann auf beliebigen Threads erzeugt werden.
|
||||||
|
* <p>
|
||||||
|
* Die Beschreibungszeilen ({@link #correctionLines}) entsprechen den
|
||||||
|
* {@link CorrectionSuggestion#descriptionForUser()}-Texten der im Plan enthaltenen
|
||||||
|
* Vorschläge in Reihenfolge.
|
||||||
|
*
|
||||||
|
* @param title deutscher Dialogtitel; nie {@code null}
|
||||||
|
* @param introText einleitender deutschsprachiger Text; nie {@code null}
|
||||||
|
* @param correctionLines Liste der deutschen Beschreibungszeilen, eine pro Korrekturmaßnahme;
|
||||||
|
* nie {@code null}
|
||||||
|
*/
|
||||||
|
public record ConfirmationDialogContent(
|
||||||
|
String title,
|
||||||
|
String introText,
|
||||||
|
List<String> correctionLines) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Bestätigungsdialog-Inhalt.
|
||||||
|
*
|
||||||
|
* @param title Dialogtitel; darf nicht {@code null} sein
|
||||||
|
* @param introText einleitender Text; darf nicht {@code null} sein
|
||||||
|
* @param correctionLines Beschreibungszeilen; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public ConfirmationDialogContent {
|
||||||
|
Objects.requireNonNull(title, "title must not be null");
|
||||||
|
Objects.requireNonNull(introText, "introText must not be null");
|
||||||
|
Objects.requireNonNull(correctionLines, "correctionLines must not be null");
|
||||||
|
correctionLines = List.copyOf(correctionLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt den Bestätigungsdialog-Inhalt aus einem {@link CorrectionPlan}.
|
||||||
|
* <p>
|
||||||
|
* Die Beschreibungszeilen werden aus den
|
||||||
|
* {@link CorrectionSuggestion#descriptionForUser()}-Texten der Vorschläge im Plan
|
||||||
|
* in Reihenfolge übernommen.
|
||||||
|
*
|
||||||
|
* @param plan Korrekturplan; darf nicht {@code null} sein
|
||||||
|
* @return ein neuer Dialoginhalt; nie {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code plan} {@code null} ist
|
||||||
|
*/
|
||||||
|
public static ConfirmationDialogContent fromPlan(CorrectionPlan plan) {
|
||||||
|
Objects.requireNonNull(plan, "plan must not be null");
|
||||||
|
List<String> lines = plan.suggestions().stream()
|
||||||
|
.map(CorrectionSuggestion::descriptionForUser)
|
||||||
|
.toList();
|
||||||
|
return new ConfirmationDialogContent(
|
||||||
|
"Korrekturen bestätigen",
|
||||||
|
"Folgende technische Korrekturen werden durchgeführt:",
|
||||||
|
lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt an, ob der Dialoginhalt mindestens eine Beschreibungszeile enthält.
|
||||||
|
*
|
||||||
|
* @return {@code true} wenn mindestens eine Korrekturmaßnahme beschrieben ist
|
||||||
|
*/
|
||||||
|
public boolean hasCorrections() {
|
||||||
|
return !correctionLines.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -92,6 +92,7 @@ public final class GuiApiKeyMerger {
|
|||||||
current.maxRetriesTransient(),
|
current.maxRetriesTransient(),
|
||||||
current.maxPages(),
|
current.maxPages(),
|
||||||
current.maxTextCharacters(),
|
current.maxTextCharacters(),
|
||||||
|
current.maxTitleLength(),
|
||||||
current.logAiSensitive(),
|
current.logAiSensitive(),
|
||||||
current.activeProviderFamily(),
|
current.activeProviderFamily(),
|
||||||
merged);
|
merged);
|
||||||
|
|||||||
+2
-2
@@ -29,10 +29,10 @@ public record GuiConfigurationEditorState(
|
|||||||
* @param values current editable configuration values; must not be {@code null}
|
* @param values current editable configuration values; must not be {@code null}
|
||||||
*/
|
*/
|
||||||
public GuiConfigurationEditorState {
|
public GuiConfigurationEditorState {
|
||||||
loadedFileSnapshot = loadedFileSnapshot == null ? Optional.empty() : loadedFileSnapshot;
|
loadedFileSnapshot = Objects.requireNonNullElse(loadedFileSnapshot, Optional.empty());
|
||||||
baselineValues = Objects.requireNonNull(baselineValues, "baselineValues must not be null");
|
baselineValues = Objects.requireNonNull(baselineValues, "baselineValues must not be null");
|
||||||
values = Objects.requireNonNull(values, "values must not be null");
|
values = Objects.requireNonNull(values, "values must not be null");
|
||||||
pendingMigrationMessage = pendingMigrationMessage == null ? Optional.empty() : pendingMigrationMessage;
|
pendingMigrationMessage = Objects.requireNonNullElse(pendingMigrationMessage, Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
@@ -25,6 +25,7 @@ public final class GuiConfigurationEditorStateFactory {
|
|||||||
private static final String PROP_MAX_RETRIES_TRANSIENT = "max.retries.transient";
|
private static final String PROP_MAX_RETRIES_TRANSIENT = "max.retries.transient";
|
||||||
private static final String PROP_MAX_PAGES = "max.pages";
|
private static final String PROP_MAX_PAGES = "max.pages";
|
||||||
private static final String PROP_MAX_TEXT_CHARACTERS = "max.text.characters";
|
private static final String PROP_MAX_TEXT_CHARACTERS = "max.text.characters";
|
||||||
|
private static final String PROP_MAX_TITLE_LENGTH = "max.title.length";
|
||||||
private static final String PROP_LOG_AI_SENSITIVE = "log.ai.sensitive";
|
private static final String PROP_LOG_AI_SENSITIVE = "log.ai.sensitive";
|
||||||
private static final String PROP_ACTIVE_PROVIDER = "ai.provider.active";
|
private static final String PROP_ACTIVE_PROVIDER = "ai.provider.active";
|
||||||
private static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
|
private static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
|
||||||
@@ -74,6 +75,7 @@ public final class GuiConfigurationEditorStateFactory {
|
|||||||
propertyOrBlank(properties, PROP_MAX_RETRIES_TRANSIENT),
|
propertyOrBlank(properties, PROP_MAX_RETRIES_TRANSIENT),
|
||||||
propertyOrBlank(properties, PROP_MAX_PAGES),
|
propertyOrBlank(properties, PROP_MAX_PAGES),
|
||||||
propertyOrBlank(properties, PROP_MAX_TEXT_CHARACTERS),
|
propertyOrBlank(properties, PROP_MAX_TEXT_CHARACTERS),
|
||||||
|
propertyOrBlank(properties, PROP_MAX_TITLE_LENGTH),
|
||||||
propertyOrBlank(properties, PROP_LOG_AI_SENSITIVE),
|
propertyOrBlank(properties, PROP_LOG_AI_SENSITIVE),
|
||||||
propertyOrBlank(properties, PROP_ACTIVE_PROVIDER),
|
propertyOrBlank(properties, PROP_ACTIVE_PROVIDER),
|
||||||
providerConfigurations);
|
providerConfigurations);
|
||||||
|
|||||||
+22
-6
@@ -23,7 +23,8 @@ public final class GuiConfigurationTemplateFactory {
|
|||||||
private static final String LOG_LEVEL = "INFO";
|
private static final String LOG_LEVEL = "INFO";
|
||||||
private static final String MAX_RETRIES_TRANSIENT = "3";
|
private static final String MAX_RETRIES_TRANSIENT = "3";
|
||||||
private static final String MAX_PAGES = "10";
|
private static final String MAX_PAGES = "10";
|
||||||
private static final String MAX_TEXT_CHARACTERS = "5000";
|
private static final String MAX_TEXT_CHARACTERS = "1000";
|
||||||
|
private static final String DEFAULT_MAX_TITLE_LENGTH = "60";
|
||||||
|
|
||||||
private static final String OPENAI_BASE_URL = "https://api.openai.com/v1";
|
private static final String OPENAI_BASE_URL = "https://api.openai.com/v1";
|
||||||
private static final String OPENAI_MODEL = "gpt-4o-mini";
|
private static final String OPENAI_MODEL = "gpt-4o-mini";
|
||||||
@@ -48,15 +49,28 @@ public final class GuiConfigurationTemplateFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the empty editor state used when the GUI starts without a loaded configuration.
|
* Creates the editor state used when the GUI starts without a loaded configuration.
|
||||||
* <p>
|
* <p>
|
||||||
* This start state intentionally does not show the standard template yet. The template
|
* The start state contains the standard configuration template so the GUI shows the
|
||||||
* is reserved for the explicit {@code Neu} action so the GUI starts without an implicit
|
* default values immediately, equivalent to the explicit {@code Neu} action having been
|
||||||
* draft and only shows the welcome guidance until the user requests a new configuration.
|
* triggered. No file snapshot is associated with the state.
|
||||||
*
|
*
|
||||||
* @return a clean editor state without a loaded file snapshot and without template values
|
* @return a clean editor state with the standard template values and no loaded file snapshot
|
||||||
*/
|
*/
|
||||||
public static GuiConfigurationEditorState createBlankStartState() {
|
public static GuiConfigurationEditorState createBlankStartState() {
|
||||||
|
return createStandardTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a truly empty editor state without any template values.
|
||||||
|
* <p>
|
||||||
|
* This factory is reserved for tests that intentionally need an editor state with empty
|
||||||
|
* field values and no provider configurations. Production startup uses
|
||||||
|
* {@link #createBlankStartState()} which returns the standard template instead.
|
||||||
|
*
|
||||||
|
* @return a clean editor state without any template values
|
||||||
|
*/
|
||||||
|
public static GuiConfigurationEditorState createEmptyStartState() {
|
||||||
GuiConfigurationValues blankValues = new GuiConfigurationValues(
|
GuiConfigurationValues blankValues = new GuiConfigurationValues(
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
@@ -70,6 +84,7 @@ public final class GuiConfigurationTemplateFactory {
|
|||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
|
"",
|
||||||
Map.of());
|
Map.of());
|
||||||
return new GuiConfigurationEditorState(Optional.empty(), blankValues, blankValues, Optional.empty());
|
return new GuiConfigurationEditorState(Optional.empty(), blankValues, blankValues, Optional.empty());
|
||||||
}
|
}
|
||||||
@@ -103,6 +118,7 @@ public final class GuiConfigurationTemplateFactory {
|
|||||||
MAX_RETRIES_TRANSIENT,
|
MAX_RETRIES_TRANSIENT,
|
||||||
MAX_PAGES,
|
MAX_PAGES,
|
||||||
MAX_TEXT_CHARACTERS,
|
MAX_TEXT_CHARACTERS,
|
||||||
|
DEFAULT_MAX_TITLE_LENGTH,
|
||||||
Boolean.toString(false),
|
Boolean.toString(false),
|
||||||
AiProviderFamily.CLAUDE.getIdentifier(),
|
AiProviderFamily.CLAUDE.getIdentifier(),
|
||||||
providerConfigurations);
|
providerConfigurations);
|
||||||
|
|||||||
+29
-13
@@ -23,6 +23,7 @@ import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
|||||||
* @param maxRetriesTransient transient retry limit as editable text
|
* @param maxRetriesTransient transient retry limit as editable text
|
||||||
* @param maxPages page limit as editable text
|
* @param maxPages page limit as editable text
|
||||||
* @param maxTextCharacters text limit as editable text
|
* @param maxTextCharacters text limit as editable text
|
||||||
|
* @param maxTitleLength maximum base-title length as editable text
|
||||||
* @param logAiSensitive raw value of {@code log.ai.sensitive} as editable text
|
* @param logAiSensitive raw value of {@code log.ai.sensitive} as editable text
|
||||||
* @param activeProviderFamily raw value of {@code ai.provider.active} as editable text
|
* @param activeProviderFamily raw value of {@code ai.provider.active} as editable text
|
||||||
* @param providerConfigurations provider-specific editor state keyed by provider family
|
* @param providerConfigurations provider-specific editor state keyed by provider family
|
||||||
@@ -38,6 +39,7 @@ public record GuiConfigurationValues(
|
|||||||
String maxRetriesTransient,
|
String maxRetriesTransient,
|
||||||
String maxPages,
|
String maxPages,
|
||||||
String maxTextCharacters,
|
String maxTextCharacters,
|
||||||
|
String maxTitleLength,
|
||||||
String logAiSensitive,
|
String logAiSensitive,
|
||||||
String activeProviderFamily,
|
String activeProviderFamily,
|
||||||
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
|
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
|
||||||
@@ -55,6 +57,7 @@ public record GuiConfigurationValues(
|
|||||||
* @param maxRetriesTransient transient retry limit; {@code null} becomes an empty string
|
* @param maxRetriesTransient transient retry limit; {@code null} becomes an empty string
|
||||||
* @param maxPages page limit; {@code null} becomes an empty string
|
* @param maxPages page limit; {@code null} becomes an empty string
|
||||||
* @param maxTextCharacters text limit; {@code null} becomes an empty string
|
* @param maxTextCharacters text limit; {@code null} becomes an empty string
|
||||||
|
* @param maxTitleLength maximum base-title length; {@code null} becomes an empty string
|
||||||
* @param logAiSensitive raw {@code log.ai.sensitive} value; {@code null} becomes an empty string
|
* @param logAiSensitive raw {@code log.ai.sensitive} value; {@code null} becomes an empty string
|
||||||
* @param activeProviderFamily raw {@code ai.provider.active} value; {@code null} becomes an empty string
|
* @param activeProviderFamily raw {@code ai.provider.active} value; {@code null} becomes an empty string
|
||||||
* @param providerConfigurations provider-specific state map; must not be {@code null}
|
* @param providerConfigurations provider-specific state map; must not be {@code null}
|
||||||
@@ -70,6 +73,7 @@ public record GuiConfigurationValues(
|
|||||||
maxRetriesTransient = normalizeText(maxRetriesTransient);
|
maxRetriesTransient = normalizeText(maxRetriesTransient);
|
||||||
maxPages = normalizeText(maxPages);
|
maxPages = normalizeText(maxPages);
|
||||||
maxTextCharacters = normalizeText(maxTextCharacters);
|
maxTextCharacters = normalizeText(maxTextCharacters);
|
||||||
|
maxTitleLength = normalizeText(maxTitleLength);
|
||||||
logAiSensitive = normalizeText(logAiSensitive);
|
logAiSensitive = normalizeText(logAiSensitive);
|
||||||
activeProviderFamily = normalizeText(activeProviderFamily);
|
activeProviderFamily = normalizeText(activeProviderFamily);
|
||||||
|
|
||||||
@@ -98,7 +102,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withActiveProviderFamily(String providerFamily) {
|
public GuiConfigurationValues withActiveProviderFamily(String providerFamily) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, providerFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, providerFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,7 +114,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withSourceFolder(String value) {
|
public GuiConfigurationValues withSourceFolder(String value) {
|
||||||
return new GuiConfigurationValues(value, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(value, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,7 +126,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withTargetFolder(String value) {
|
public GuiConfigurationValues withTargetFolder(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, value, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, value, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +138,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withSqliteFile(String value) {
|
public GuiConfigurationValues withSqliteFile(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, value, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, value, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,7 +150,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withPromptTemplateFile(String value) {
|
public GuiConfigurationValues withPromptTemplateFile(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, value,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, value,
|
||||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,7 +162,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withRuntimeLockFile(String value) {
|
public GuiConfigurationValues withRuntimeLockFile(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
value, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
value, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,7 +174,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withLogDirectory(String value) {
|
public GuiConfigurationValues withLogDirectory(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, value, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
runtimeLockFile, value, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,7 +186,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withLogLevel(String value) {
|
public GuiConfigurationValues withLogLevel(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, value, maxRetriesTransient, maxPages, maxTextCharacters,
|
runtimeLockFile, logDirectory, value, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,7 +198,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withMaxRetriesTransient(String value) {
|
public GuiConfigurationValues withMaxRetriesTransient(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, logLevel, value, maxPages, maxTextCharacters,
|
runtimeLockFile, logDirectory, logLevel, value, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,7 +210,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withMaxPages(String value) {
|
public GuiConfigurationValues withMaxPages(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, value, maxTextCharacters,
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, value, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,7 +222,19 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withMaxTextCharacters(String value) {
|
public GuiConfigurationValues withMaxTextCharacters(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, value,
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, value,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with a different maximum base-title length value.
|
||||||
|
*
|
||||||
|
* @param value new value; {@code null} becomes an empty string
|
||||||
|
* @return a new configuration values object with the requested title-length value
|
||||||
|
*/
|
||||||
|
public GuiConfigurationValues withMaxTitleLength(String value) {
|
||||||
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
|
value, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,7 +246,7 @@ public record GuiConfigurationValues(
|
|||||||
public GuiConfigurationValues withLogAiSensitive(String value) {
|
public GuiConfigurationValues withLogAiSensitive(String value) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
value, activeProviderFamily, providerConfigurations);
|
maxTitleLength, value, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,7 +259,7 @@ public record GuiConfigurationValues(
|
|||||||
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
|
Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations) {
|
||||||
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
return new GuiConfigurationValues(sourceFolder, targetFolder, sqliteFile, promptTemplateFile,
|
||||||
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
runtimeLockFile, logDirectory, logLevel, maxRetriesTransient, maxPages, maxTextCharacters,
|
||||||
logAiSensitive, activeProviderFamily, providerConfigurations);
|
maxTitleLength, logAiSensitive, activeProviderFamily, providerConfigurations);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the result of validating the current editor state in the GUI configuration editor.
|
||||||
|
* <p>
|
||||||
|
* Each validation run produces one immutable result containing all findings split into two
|
||||||
|
* complementary views:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code messages} – a consolidated list of {@link GuiMessageEntry} objects that feed
|
||||||
|
* the central message area.</li>
|
||||||
|
* <li>{@code fieldFindings} – field-specific {@link GuiFieldFinding} objects that are
|
||||||
|
* rendered directly below the affected input fields.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* A single root cause may appear in both lists: once as a central message (with full context)
|
||||||
|
* and once as a compact field finding (with a short, field-specific description).
|
||||||
|
* <p>
|
||||||
|
* The {@code evaluatedAt} timestamp records when the validation ran; the GUI may use it to
|
||||||
|
* determine whether a displayed result is still current.
|
||||||
|
* <p>
|
||||||
|
* This record contains no JavaFX references and can be created and inspected on any thread.
|
||||||
|
*
|
||||||
|
* @param messages consolidated list of message entries for the central message area;
|
||||||
|
* never {@code null}
|
||||||
|
* @param fieldFindings list of field-level findings; never {@code null}
|
||||||
|
* @param evaluatedAt instant at which the validation was performed; never {@code null}
|
||||||
|
*/
|
||||||
|
public record GuiEditorValidationResult(
|
||||||
|
List<GuiMessageEntry> messages,
|
||||||
|
List<GuiFieldFinding> fieldFindings,
|
||||||
|
Instant evaluatedAt) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new validation result.
|
||||||
|
*
|
||||||
|
* @param messages central message entries; must not be {@code null}
|
||||||
|
* @param fieldFindings field-level findings; must not be {@code null}
|
||||||
|
* @param evaluatedAt validation timestamp; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
*/
|
||||||
|
public GuiEditorValidationResult {
|
||||||
|
Objects.requireNonNull(messages, "messages must not be null");
|
||||||
|
Objects.requireNonNull(fieldFindings, "fieldFindings must not be null");
|
||||||
|
Objects.requireNonNull(evaluatedAt, "evaluatedAt must not be null");
|
||||||
|
messages = List.copyOf(messages);
|
||||||
|
fieldFindings = List.copyOf(fieldFindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an empty validation result representing the state before any validation has run.
|
||||||
|
* <p>
|
||||||
|
* Callers must not interpret an empty result as "no errors found"; they should wait for
|
||||||
|
* a non-empty result from the first actual validation run.
|
||||||
|
*
|
||||||
|
* @return an empty result with the current instant as timestamp; never {@code null}
|
||||||
|
*/
|
||||||
|
public static GuiEditorValidationResult empty() {
|
||||||
|
return new GuiEditorValidationResult(List.of(), List.of(), Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when at least one message or field finding has severity
|
||||||
|
* {@link GuiMessageSeverity#ERROR}.
|
||||||
|
* <p>
|
||||||
|
* A result with errors indicates that the current editor state is not operational and
|
||||||
|
* should not be relied upon to start a processing run without correction.
|
||||||
|
*
|
||||||
|
* @return {@code true} when at least one error is present
|
||||||
|
*/
|
||||||
|
public boolean hasErrors() {
|
||||||
|
boolean messageError = messages.stream()
|
||||||
|
.anyMatch(m -> m.severity() == GuiMessageSeverity.ERROR);
|
||||||
|
boolean fieldError = fieldFindings.stream()
|
||||||
|
.anyMatch(f -> f.severity() == GuiMessageSeverity.ERROR);
|
||||||
|
return messageError || fieldError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when the findings list contains at least one finding for the
|
||||||
|
* requested field key, regardless of severity.
|
||||||
|
*
|
||||||
|
* @param fieldKey the property key to look up; must not be {@code null}
|
||||||
|
* @return {@code true} when at least one finding refers to the requested field
|
||||||
|
*/
|
||||||
|
public boolean hasFieldFindingFor(String fieldKey) {
|
||||||
|
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||||
|
return fieldFindings.stream().anyMatch(f -> f.fieldKey().equals(fieldKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a field-level validation finding that is displayed directly below the affected
|
||||||
|
* input field in the GUI configuration editor.
|
||||||
|
* <p>
|
||||||
|
* Field-level findings complement the central message area: the central area shows all findings
|
||||||
|
* in a consolidated list while this type carries the finding directly to the specific field it
|
||||||
|
* relates to, making it easier for the user to identify and correct the problem.
|
||||||
|
* <p>
|
||||||
|
* The {@code fieldKey} uses the property key as defined in the {@code .properties} file
|
||||||
|
* (e.g., {@code "source.folder"}, {@code "ai.provider.openai-compatible.apiKey"}).
|
||||||
|
* Using the property key as the field identifier keeps the validation model stable and independent
|
||||||
|
* of GUI layout changes.
|
||||||
|
* <p>
|
||||||
|
* Field-level findings are always rendered as small red German-language text directly beneath
|
||||||
|
* the affected control. Findings with severity {@link GuiMessageSeverity#INFO} or
|
||||||
|
* {@link GuiMessageSeverity#HINT} may also be shown field-near when the context is helpful.
|
||||||
|
* <p>
|
||||||
|
* This record contains no JavaFX references and is safe to create on any thread.
|
||||||
|
*
|
||||||
|
* @param fieldKey the property key identifying the affected configuration field; never {@code null}
|
||||||
|
* @param severity the severity of this finding; never {@code null}
|
||||||
|
* @param text short, German-language description of the problem; never {@code null}
|
||||||
|
*/
|
||||||
|
public record GuiFieldFinding(
|
||||||
|
String fieldKey,
|
||||||
|
GuiMessageSeverity severity,
|
||||||
|
String text) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new field-level finding.
|
||||||
|
*
|
||||||
|
* @param fieldKey property key of the affected field; must not be {@code null}
|
||||||
|
* @param severity severity of the finding; must not be {@code null}
|
||||||
|
* @param text German-language problem description; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
*/
|
||||||
|
public GuiFieldFinding {
|
||||||
|
Objects.requireNonNull(fieldKey, "fieldKey must not be null");
|
||||||
|
Objects.requireNonNull(severity, "severity must not be null");
|
||||||
|
Objects.requireNonNull(text, "text must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an error-severity field finding.
|
||||||
|
*
|
||||||
|
* @param fieldKey property key of the affected field; must not be {@code null}
|
||||||
|
* @param text German-language problem description; must not be {@code null}
|
||||||
|
* @return a new finding with severity {@link GuiMessageSeverity#ERROR}
|
||||||
|
*/
|
||||||
|
public static GuiFieldFinding error(String fieldKey, String text) {
|
||||||
|
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.ERROR, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a warning-severity field finding.
|
||||||
|
*
|
||||||
|
* @param fieldKey property key of the affected field; must not be {@code null}
|
||||||
|
* @param text German-language problem description; must not be {@code null}
|
||||||
|
* @return a new finding with severity {@link GuiMessageSeverity#WARNING}
|
||||||
|
*/
|
||||||
|
public static GuiFieldFinding warning(String fieldKey, String text) {
|
||||||
|
return new GuiFieldFinding(fieldKey, GuiMessageSeverity.WARNING, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the model identifier entered manually by the user when no remote model list
|
||||||
|
* is available for the active provider.
|
||||||
|
* <p>
|
||||||
|
* This record captures both the provider context and the user-supplied model name so that
|
||||||
|
* later GUI layers can decide whether the value is still applicable after a provider change
|
||||||
|
* or a successful remote list retrieval.
|
||||||
|
* <p>
|
||||||
|
* A manually entered model name is discarded when a remote model list is subsequently loaded
|
||||||
|
* and the previously entered value does not appear in that list.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider for which the model was entered;
|
||||||
|
* never {@code null}
|
||||||
|
* @param modelName model identifier as typed by the user; never {@code null},
|
||||||
|
* but may be blank when the user has not yet entered anything
|
||||||
|
*/
|
||||||
|
public record GuiManualModelEntry(
|
||||||
|
String providerIdentifier,
|
||||||
|
String modelName) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new manual model entry.
|
||||||
|
*
|
||||||
|
* @param providerIdentifier identifier of the provider; must not be {@code null}
|
||||||
|
* @param modelName model name as entered by the user; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
*/
|
||||||
|
public GuiManualModelEntry {
|
||||||
|
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
||||||
|
Objects.requireNonNull(modelName, "modelName must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the model name is non-blank, i.e. whether the user has entered something.
|
||||||
|
*
|
||||||
|
* @return {@code true} when the model name contains at least one non-whitespace character
|
||||||
|
*/
|
||||||
|
public boolean hasModelName() {
|
||||||
|
return !modelName.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single entry in the central message area of the GUI configuration editor.
|
||||||
|
* <p>
|
||||||
|
* Each entry carries a severity level, the message text, an optional source label that
|
||||||
|
* identifies the subsystem that produced the message (e.g., "Modellabruf", "Validierung"),
|
||||||
|
* and a timestamp. The GUI renders the {@link GuiMessageSeverity#getPrefixLabel() prefix} of
|
||||||
|
* the severity in colour while the message text itself remains black.
|
||||||
|
* <p>
|
||||||
|
* Instances are immutable and contain no JavaFX references; they are safe to create on
|
||||||
|
* background threads and pass to the JavaFX Application Thread via {@code Platform.runLater}.
|
||||||
|
*
|
||||||
|
* @param severity the severity of this message; never {@code null}
|
||||||
|
* @param text the message text; never {@code null}
|
||||||
|
* @param source optional label identifying the origin subsystem; empty when not applicable
|
||||||
|
* @param timestamp the instant at which the message was created; never {@code null}
|
||||||
|
*/
|
||||||
|
public record GuiMessageEntry(
|
||||||
|
GuiMessageSeverity severity,
|
||||||
|
String text,
|
||||||
|
Optional<String> source,
|
||||||
|
Instant timestamp) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new message entry.
|
||||||
|
*
|
||||||
|
* @param severity severity level; must not be {@code null}
|
||||||
|
* @param text message text; must not be {@code null}
|
||||||
|
* @param source optional source label; {@code null} is treated as {@link Optional#empty()}
|
||||||
|
* @param timestamp creation timestamp; must not be {@code null}
|
||||||
|
* @throws NullPointerException if {@code severity}, {@code text}, or {@code timestamp} is {@code null}
|
||||||
|
*/
|
||||||
|
public GuiMessageEntry {
|
||||||
|
Objects.requireNonNull(severity, "severity must not be null");
|
||||||
|
Objects.requireNonNull(text, "text must not be null");
|
||||||
|
Objects.requireNonNull(timestamp, "timestamp must not be null");
|
||||||
|
source = Objects.requireNonNullElse(source, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a message entry without a source label, using the current instant as timestamp.
|
||||||
|
*
|
||||||
|
* @param severity severity level; must not be {@code null}
|
||||||
|
* @param text message text; must not be {@code null}
|
||||||
|
* @return a new entry; never {@code null}
|
||||||
|
*/
|
||||||
|
public static GuiMessageEntry of(GuiMessageSeverity severity, String text) {
|
||||||
|
return new GuiMessageEntry(severity, text, Optional.empty(), Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a message entry with a source label, using the current instant as timestamp.
|
||||||
|
*
|
||||||
|
* @param severity severity level; must not be {@code null}
|
||||||
|
* @param text message text; must not be {@code null}
|
||||||
|
* @param source source subsystem label; must not be {@code null}
|
||||||
|
* @return a new entry; never {@code null}
|
||||||
|
*/
|
||||||
|
public static GuiMessageEntry of(GuiMessageSeverity severity, String text, String source) {
|
||||||
|
Objects.requireNonNull(source, "source must not be null");
|
||||||
|
return new GuiMessageEntry(severity, text, Optional.of(source), Instant.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the four fixed severity levels for messages displayed in the central message area
|
||||||
|
* of the GUI configuration editor.
|
||||||
|
* <p>
|
||||||
|
* Each level carries a German-language prefix string that is displayed in colour at the start
|
||||||
|
* of each message line. The remainder of the message text is always rendered in black,
|
||||||
|
* regardless of severity.
|
||||||
|
* <p>
|
||||||
|
* The colour hints in this enum are expressed as CSS colour strings to avoid a compile-time
|
||||||
|
* dependency on JavaFX. Rendering code in the GUI layer must convert the hint to a
|
||||||
|
* {@code javafx.scene.paint.Color} or equivalent.
|
||||||
|
* <p>
|
||||||
|
* Severity levels ordered from least to most critical:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@link #INFO}</li>
|
||||||
|
* <li>{@link #HINT}</li>
|
||||||
|
* <li>{@link #WARNING}</li>
|
||||||
|
* <li>{@link #ERROR}</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* This enum contains no JavaFX references and is safe to use in unit-tested view-model code.
|
||||||
|
*/
|
||||||
|
public enum GuiMessageSeverity {
|
||||||
|
|
||||||
|
/** Neutral informational message, no action required. */
|
||||||
|
INFO("Info:", "#1565c0"),
|
||||||
|
|
||||||
|
/** Helpful hint that the user may want to act on. */
|
||||||
|
HINT("Hinweis:", "#558b2f"),
|
||||||
|
|
||||||
|
/** Configuration value is technically acceptable but risky or unusual. */
|
||||||
|
WARNING("Warnung:", "#e65100"),
|
||||||
|
|
||||||
|
/** Configuration value is invalid or the state is not operational. */
|
||||||
|
ERROR("Fehler:", "#b71c1c");
|
||||||
|
|
||||||
|
private final String prefixLabel;
|
||||||
|
private final String prefixCssColour;
|
||||||
|
|
||||||
|
GuiMessageSeverity(String prefixLabel, String prefixCssColour) {
|
||||||
|
this.prefixLabel = prefixLabel;
|
||||||
|
this.prefixCssColour = prefixCssColour;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the German-language prefix label shown at the start of each message line of this severity.
|
||||||
|
* <p>
|
||||||
|
* Only the prefix is rendered in colour; the remaining message text is always black.
|
||||||
|
*
|
||||||
|
* @return the prefix label; never {@code null}
|
||||||
|
*/
|
||||||
|
public String getPrefixLabel() {
|
||||||
|
return prefixLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a CSS colour string hint that the GUI layer uses to render the prefix in the
|
||||||
|
* appropriate colour.
|
||||||
|
* <p>
|
||||||
|
* The returned value is a CSS hex colour (e.g., {@code "#b71c1c"}) that can be passed to
|
||||||
|
* {@code Color.web()} in JavaFX. The GUI layer is responsible for this conversion; this
|
||||||
|
* enum itself contains no JavaFX dependency.
|
||||||
|
*
|
||||||
|
* @return a CSS colour hint string; never {@code null}
|
||||||
|
*/
|
||||||
|
public String getPrefixCssColour() {
|
||||||
|
return prefixCssColour;
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.Node;
|
||||||
|
import javafx.scene.control.ComboBox;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.StackPane;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container that switches between a non-editable {@link ComboBox} and a manual {@link TextField}
|
||||||
|
* for model identifier input, depending on the current {@link GuiModelSource}.
|
||||||
|
* <p>
|
||||||
|
* When the model source is {@link GuiModelSource#LIST_REMOTE_SUCCESS}, a non-editable
|
||||||
|
* {@code ComboBox} is shown, pre-populated with the remote list and with the first model
|
||||||
|
* pre-selected. In all other cases (including {@link GuiModelSource#NOT_YET_LOADED}) the
|
||||||
|
* manual text field is shown, which may be empty or disabled depending on the source state.
|
||||||
|
* <p>
|
||||||
|
* Exactly one child is {@code visible} and {@code managed} at any time. The other child is
|
||||||
|
* kept in the scene graph with both flags set to {@code false} so that no blank space appears.
|
||||||
|
* <p>
|
||||||
|
* This class contains JavaFX references and must only be used on the JavaFX Application Thread.
|
||||||
|
*/
|
||||||
|
public final class GuiModelFieldContainer extends StackPane {
|
||||||
|
|
||||||
|
private final ComboBox<String> comboBox;
|
||||||
|
private final TextField textField;
|
||||||
|
private final Consumer<String> onModelChange;
|
||||||
|
|
||||||
|
private GuiModelSource currentSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard flag that suppresses the change callback while the text field value is being set
|
||||||
|
* programmatically via {@link #setTextFieldValue(String)}. The callback must still fire on
|
||||||
|
* genuine user edits, so the guard is scoped tightly around the programmatic write only.
|
||||||
|
*/
|
||||||
|
private boolean programmaticTextFieldSet = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new model field container.
|
||||||
|
*
|
||||||
|
* @param initialModelValue the initial model text shown in the text field; may be blank
|
||||||
|
* @param onModelChange callback invoked on every model-value change; must not be {@code null}
|
||||||
|
*/
|
||||||
|
public GuiModelFieldContainer(String initialModelValue, Consumer<String> onModelChange) {
|
||||||
|
this.onModelChange = Objects.requireNonNull(onModelChange, "onModelChange must not be null");
|
||||||
|
this.currentSource = GuiModelSource.NOT_YET_LOADED;
|
||||||
|
|
||||||
|
this.textField = new TextField(initialModelValue == null ? "" : initialModelValue);
|
||||||
|
this.textField.textProperty().addListener((obs, oldText, newText) -> {
|
||||||
|
if (!programmaticTextFieldSet && !newText.equals(oldText)) {
|
||||||
|
onModelChange.accept(newText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.comboBox = new ComboBox<>();
|
||||||
|
this.comboBox.setEditable(false);
|
||||||
|
this.comboBox.valueProperty().addListener((obs, oldVal, newVal) -> {
|
||||||
|
if (newVal != null && !newVal.equals(oldVal)) {
|
||||||
|
onModelChange.accept(newVal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial state: show text field (NOT_YET_LOADED → manual input)
|
||||||
|
applyVisibility(false);
|
||||||
|
setAlignment(Pos.CENTER_LEFT);
|
||||||
|
getChildren().addAll(comboBox, textField);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently displayed model value.
|
||||||
|
* <p>
|
||||||
|
* When the {@code ComboBox} is active, returns the selected item. When the text field
|
||||||
|
* is active, returns the text field content. Never returns {@code null}.
|
||||||
|
*
|
||||||
|
* @return the current model value; never {@code null}
|
||||||
|
*/
|
||||||
|
public String currentModelValue() {
|
||||||
|
if (currentSource == GuiModelSource.LIST_REMOTE_SUCCESS) {
|
||||||
|
String val = comboBox.getValue();
|
||||||
|
return val == null ? "" : val;
|
||||||
|
}
|
||||||
|
return textField.getText() == null ? "" : textField.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current model source state.
|
||||||
|
*
|
||||||
|
* @return the current {@link GuiModelSource}; never {@code null}
|
||||||
|
*/
|
||||||
|
public GuiModelSource currentSource() {
|
||||||
|
return currentSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a successful model list and switches to the non-editable {@link ComboBox}.
|
||||||
|
* <p>
|
||||||
|
* If the previously active manual text value is present in the new list it is kept as the
|
||||||
|
* selection; otherwise the first model in the list is pre-selected and the former manual
|
||||||
|
* value is discarded.
|
||||||
|
* <p>
|
||||||
|
* Must be called on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param models non-empty list of model identifiers; must not be {@code null} or empty
|
||||||
|
* @param previousManualValue the model text that was in the text field before this call;
|
||||||
|
* used to decide whether to preserve the selection
|
||||||
|
* @throws IllegalArgumentException if {@code models} is empty
|
||||||
|
*/
|
||||||
|
public void applyModelList(List<String> models, String previousManualValue) {
|
||||||
|
Objects.requireNonNull(models, "models must not be null");
|
||||||
|
if (models.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("models must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
comboBox.getItems().setAll(models);
|
||||||
|
|
||||||
|
// Preserve the previous value only when it appears in the new list.
|
||||||
|
String previous = previousManualValue == null ? "" : previousManualValue;
|
||||||
|
if (!previous.isBlank() && models.contains(previous)) {
|
||||||
|
comboBox.setValue(previous);
|
||||||
|
} else {
|
||||||
|
comboBox.setValue(models.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSource = GuiModelSource.LIST_REMOTE_SUCCESS;
|
||||||
|
applyVisibility(true);
|
||||||
|
// Notify the callback about the newly selected value.
|
||||||
|
String selected = comboBox.getValue();
|
||||||
|
if (selected != null) {
|
||||||
|
onModelChange.accept(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to the manual text field with the given fallback source state.
|
||||||
|
* <p>
|
||||||
|
* The text field retains whatever value it currently holds (or the value set programmatically
|
||||||
|
* via {@link #setTextFieldValue(String)}). Must be called on the JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param source the non-success source state; must not be {@link GuiModelSource#LIST_REMOTE_SUCCESS}
|
||||||
|
*/
|
||||||
|
public void applyManualFallback(GuiModelSource source) {
|
||||||
|
Objects.requireNonNull(source, "source must not be null");
|
||||||
|
if (source == GuiModelSource.LIST_REMOTE_SUCCESS) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"applyManualFallback must not be called with LIST_REMOTE_SUCCESS");
|
||||||
|
}
|
||||||
|
currentSource = source;
|
||||||
|
applyVisibility(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatically sets the text field value without triggering the change callback.
|
||||||
|
* <p>
|
||||||
|
* Useful for restoring a saved model value after a provider switch. Must be called on the
|
||||||
|
* JavaFX Application Thread.
|
||||||
|
*
|
||||||
|
* @param value the new text field value; {@code null} is treated as an empty string
|
||||||
|
*/
|
||||||
|
public void setTextFieldValue(String value) {
|
||||||
|
programmaticTextFieldSet = true;
|
||||||
|
try {
|
||||||
|
textField.setText(value == null ? "" : value);
|
||||||
|
} finally {
|
||||||
|
programmaticTextFieldSet = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf beide internen Controls
|
||||||
|
* (ComboBox und TextField). Damit erscheint der Tooltip unabhängig davon, welches der
|
||||||
|
* beiden Controls gerade sichtbar ist.
|
||||||
|
* <p>
|
||||||
|
* Darf nur auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param tooltipText der anzuzeigende Tooltip-Text; darf nicht leer sein
|
||||||
|
*/
|
||||||
|
public void applyTooltip(String tooltipText) {
|
||||||
|
Objects.requireNonNull(tooltipText, "tooltipText darf nicht null sein");
|
||||||
|
Tooltip comboTooltip = new Tooltip(tooltipText);
|
||||||
|
comboTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
comboBox.setTooltip(comboTooltip);
|
||||||
|
|
||||||
|
Tooltip textTooltip = new Tooltip(tooltipText);
|
||||||
|
textTooltip.setShowDelay(Duration.millis(300));
|
||||||
|
textField.setTooltip(textTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the JavaFX node that represents this container and can be added to the scene graph.
|
||||||
|
*
|
||||||
|
* @return {@code this} container; never {@code null}
|
||||||
|
*/
|
||||||
|
public Node asNode() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies visibility to the ComboBox and TextField based on whether the list is active.
|
||||||
|
*
|
||||||
|
* @param listActive {@code true} to show the ComboBox, {@code false} to show the TextField
|
||||||
|
*/
|
||||||
|
private void applyVisibility(boolean listActive) {
|
||||||
|
comboBox.setVisible(listActive);
|
||||||
|
comboBox.setManaged(listActive);
|
||||||
|
textField.setVisible(!listActive);
|
||||||
|
textField.setManaged(!listActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the origin of the currently displayed model value in the GUI model selection area.
|
||||||
|
* <p>
|
||||||
|
* The GUI uses this enum to decide which control to show for the model input:
|
||||||
|
* <ul>
|
||||||
|
* <li>When the source is {@link #LIST_REMOTE_SUCCESS}, a non-editable {@code ComboBox}
|
||||||
|
* is shown, pre-populated with the remote list.</li>
|
||||||
|
* <li>When the source is {@link #LIST_UNAVAILABLE_MANUAL_INPUT} or
|
||||||
|
* {@link #LIST_FAILED_MANUAL_INPUT}, a plain text input field is shown instead,
|
||||||
|
* allowing the user to enter the model name manually.</li>
|
||||||
|
* <li>{@link #NOT_YET_LOADED} represents the initial state before the first retrieval
|
||||||
|
* attempt; the GUI should render a loading indicator or show the text field
|
||||||
|
* in a disabled/pending state.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* This enum is intentionally free of JavaFX references so it can be used in unit-tested
|
||||||
|
* view-model code without starting a JavaFX runtime.
|
||||||
|
*/
|
||||||
|
public enum GuiModelSource {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model list was successfully retrieved from the remote provider endpoint.
|
||||||
|
* <p>
|
||||||
|
* A non-editable {@code ComboBox} is displayed, pre-selecting the first available model.
|
||||||
|
*/
|
||||||
|
LIST_REMOTE_SUCCESS,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No model list is available because the provider does not expose a model catalogue endpoint
|
||||||
|
* or because the configuration was incomplete.
|
||||||
|
* <p>
|
||||||
|
* A manual text input field is shown and the user must enter the model identifier by hand.
|
||||||
|
*/
|
||||||
|
LIST_UNAVAILABLE_MANUAL_INPUT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A technical error occurred while retrieving the model list (e.g., HTTP error, timeout,
|
||||||
|
* authentication failure).
|
||||||
|
* <p>
|
||||||
|
* A manual text input field is shown so the user can still supply a model name; the GUI
|
||||||
|
* also reports the failure in the central message area.
|
||||||
|
*/
|
||||||
|
LIST_FAILED_MANUAL_INPUT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initial state before the first model retrieval attempt has been made.
|
||||||
|
* <p>
|
||||||
|
* The GUI should indicate that a retrieval is pending and must not present the manual
|
||||||
|
* input field as the definitive fallback until at least one retrieval attempt has completed.
|
||||||
|
*/
|
||||||
|
NOT_YET_LOADED
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents which provider section is currently visible in the GUI and preserves
|
||||||
|
* the editable configuration state of the section that is currently hidden.
|
||||||
|
* <p>
|
||||||
|
* The GUI shows exactly one provider section at a time. When the user switches the
|
||||||
|
* active provider, the previously visible section must not lose its field values.
|
||||||
|
* This record captures the current display context as an immutable snapshot so that
|
||||||
|
* view-model code can reason about visibility and data preservation without touching
|
||||||
|
* JavaFX nodes directly.
|
||||||
|
* <p>
|
||||||
|
* Instances of this record contain no JavaFX references and are safe to create and
|
||||||
|
* inspect from any thread, including unit-test threads.
|
||||||
|
*
|
||||||
|
* @param visibleProvider the provider family whose configuration section is
|
||||||
|
* currently rendered; never {@code null}
|
||||||
|
* @param visibleProviderState the editable configuration state currently displayed;
|
||||||
|
* never {@code null}
|
||||||
|
* @param hiddenProviderState the editable configuration state of the provider that
|
||||||
|
* is not shown, preserved here so it is not lost on switch;
|
||||||
|
* never {@code null}
|
||||||
|
* @param hiddenProvider the provider family whose section is currently hidden;
|
||||||
|
* never {@code null}
|
||||||
|
*/
|
||||||
|
public record GuiVisibleProviderSection(
|
||||||
|
AiProviderFamily visibleProvider,
|
||||||
|
GuiProviderConfigurationState visibleProviderState,
|
||||||
|
AiProviderFamily hiddenProvider,
|
||||||
|
GuiProviderConfigurationState hiddenProviderState) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new visible-provider section snapshot.
|
||||||
|
*
|
||||||
|
* @param visibleProvider provider whose section is shown; must not be {@code null}
|
||||||
|
* @param visibleProviderState configuration state of the visible provider; must not be {@code null}
|
||||||
|
* @param hiddenProvider provider whose section is hidden; must not be {@code null}
|
||||||
|
* @param hiddenProviderState configuration state of the hidden provider; must not be {@code null}
|
||||||
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
|
* @throws IllegalArgumentException if {@code visibleProvider} and {@code hiddenProvider} are equal
|
||||||
|
*/
|
||||||
|
public GuiVisibleProviderSection {
|
||||||
|
Objects.requireNonNull(visibleProvider, "visibleProvider must not be null");
|
||||||
|
Objects.requireNonNull(visibleProviderState, "visibleProviderState must not be null");
|
||||||
|
Objects.requireNonNull(hiddenProvider, "hiddenProvider must not be null");
|
||||||
|
Objects.requireNonNull(hiddenProviderState, "hiddenProviderState must not be null");
|
||||||
|
if (visibleProvider == hiddenProvider) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"visibleProvider and hiddenProvider must be different, but both are: " + visibleProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configuration state for the requested provider family.
|
||||||
|
*
|
||||||
|
* @param family the provider family to retrieve the state for; must not be {@code null}
|
||||||
|
* @return the state for the requested provider
|
||||||
|
* @throws IllegalArgumentException if the requested family is neither the visible nor the hidden provider
|
||||||
|
*/
|
||||||
|
public GuiProviderConfigurationState stateFor(AiProviderFamily family) {
|
||||||
|
Objects.requireNonNull(family, "family must not be null");
|
||||||
|
if (family == visibleProvider) {
|
||||||
|
return visibleProviderState;
|
||||||
|
}
|
||||||
|
if (family == hiddenProvider) {
|
||||||
|
return hiddenProviderState;
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unknown provider family: " + family);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the visible and hidden providers swapped, preserving both states.
|
||||||
|
* <p>
|
||||||
|
* The previously hidden provider becomes visible and the previously visible provider
|
||||||
|
* moves to hidden. No field values are lost during the switch.
|
||||||
|
*
|
||||||
|
* @return a new section snapshot with providers and their states swapped
|
||||||
|
*/
|
||||||
|
public GuiVisibleProviderSection switchProvider() {
|
||||||
|
return new GuiVisibleProviderSection(hiddenProvider, hiddenProviderState,
|
||||||
|
visibleProvider, visibleProviderState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with a different configuration state for the visible provider.
|
||||||
|
*
|
||||||
|
* @param newState the updated configuration state; must not be {@code null}
|
||||||
|
* @return a new section snapshot with the visible provider's state replaced
|
||||||
|
*/
|
||||||
|
public GuiVisibleProviderSection withVisibleProviderState(GuiProviderConfigurationState newState) {
|
||||||
|
Objects.requireNonNull(newState, "newState must not be null");
|
||||||
|
return new GuiVisibleProviderSection(visibleProvider, newState, hiddenProvider, hiddenProviderState);
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-6
@@ -1,12 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* Editor state and template model for the JavaFX configuration editor.
|
* Editor state and view-model types for the JavaFX configuration editor.
|
||||||
* <p>
|
* <p>
|
||||||
* This package contains the GUI-side representation of configuration data that can be edited
|
* This package contains the GUI-side representation of configuration data that can be edited
|
||||||
* independently from file I/O and validation. It separates loaded file snapshots, baseline
|
* independently from file I/O and validation. It covers:
|
||||||
* editor values, current editor values, provider-specific API key state, and the derived
|
* <ul>
|
||||||
* dirty-state view used by the GUI.
|
* <li>Loaded file snapshots, baseline editor values, current editor values and the derived
|
||||||
|
* dirty-state view ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues}).</li>
|
||||||
|
* <li>Provider-specific configuration state and API-key state
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState}).</li>
|
||||||
|
* <li>Provider section visibility and state preservation across provider switches
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection}).</li>
|
||||||
|
* <li>Model source classification, manual model entry, and the JavaFX model field container
|
||||||
|
* that switches between a non-editable {@code ComboBox} and a text field depending on
|
||||||
|
* retrieval outcome
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelSource},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiManualModelEntry},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}).</li>
|
||||||
|
* <li>Message severity, central message entries and field-level validation findings
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageSeverity},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiMessageEntry},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiFieldFinding}).</li>
|
||||||
|
* <li>The consolidated validation result that feeds both the central message area and
|
||||||
|
* field-near error display
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiEditorValidationResult}).</li>
|
||||||
|
* <li>The confirmation dialog content for collected write-corrective actions
|
||||||
|
* ({@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.ConfirmationDialogContent}).</li>
|
||||||
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* The classes in this package are intentionally free of JavaFX controls so they can be reused
|
* Most classes in this package are intentionally free of JavaFX controls so they can be used
|
||||||
* by later GUI layers without coupling the model to a particular layout implementation.
|
* in unit-tested view-model code without starting a JavaFX runtime. The exception is
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiModelFieldContainer}, which
|
||||||
|
* extends a JavaFX {@code StackPane} and must be used only on the JavaFX Application Thread.
|
||||||
|
* <p>
|
||||||
|
* Types that are not GUI-specific (API-key origin provenance, model catalogue results and
|
||||||
|
* the corresponding port contract) live in
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog} to keep the
|
||||||
|
* Application module free of GUI dependencies while allowing future non-GUI consumers
|
||||||
|
* to reuse these types without depending on this adapter module.
|
||||||
*/
|
*/
|
||||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.editor;
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultDeleteDocumentHistoryUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Löscht den Dokument-Stammsatz und alle zugehörigen Verarbeitungsversuche
|
||||||
|
* vollständig und transaktional. Die Löschung ist destruktiv und nicht
|
||||||
|
* rückgängig zu machen.
|
||||||
|
* <p>
|
||||||
|
* Die GUI muss vor dem Aufruf dieses Ports einen Bestätigungsdialog mit
|
||||||
|
* explizitem Warnhinweis anzeigen.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis die Löschung abgeschlossen ist.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiDeleteDocumentHistoryPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
* <p>
|
||||||
|
* Die Löschung erfolgt in der korrekten Reihenfolge innerhalb einer Transaktion:
|
||||||
|
* zuerst alle {@code processing_attempt}-Einträge, dann der {@code document_record}-Stammsatz.
|
||||||
|
* Ist kein Datensatz vorhanden, kehrt die Methode stillschweigend zurück.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
void deleteHistory(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die Detaildaten
|
||||||
|
* für einen ausgewählten Dokumenteintrag bereitstellt.
|
||||||
|
* <p>
|
||||||
|
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
|
||||||
|
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis das Ergebnis vollständig vorliegt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoryDetailsPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt den Stammsatz und alle Verarbeitungsversuche für den angegebenen Fingerprint.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @return Optional mit den Detaildaten, oder leer wenn kein Eintrag gefunden wurde
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
Optional<HistoryDetailsResult> loadDetails(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Es ist eine modul-interne Brücke, über die Bootstrap die Dokumentenliste
|
||||||
|
* für den Historien-Tab bereitstellt, ohne dass der GUI-Adapter direkt auf
|
||||||
|
* Repository-Implementierungen zugreift.
|
||||||
|
* <p>
|
||||||
|
* Der Parameter {@code configFilePath} wird benötigt, damit die Bootstrap-Implementierung
|
||||||
|
* die SQLite-Datenbank aus der aktuell geladenen Konfigurationsdatei ableiten kann,
|
||||||
|
* ohne den Pfad global zu speichern.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis das Ergebnis vollständig vorliegt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoryOverviewPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die gefilterte Dokumentenübersicht für den Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Bei mehr als 500 Treffern enthält das Ergebnis genau 500 Zeilen und
|
||||||
|
* {@link HistoryOverviewResult#hasMore()} liefert {@code true}.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param query Abfrageparameter mit Suchtext, Status-Filter und Limit;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @return Ergebnisobjekt mit Trefferliste und {@code hasMore}-Flag; nie {@code null}
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
HistoryOverviewResult loadOverview(Path configFilePath, HistoryQuery query);
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-internes Bridge-Interface zwischen dem Historien-Tab und dem
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryResetDocumentStatusUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Führt einen feldgenauen Status-Reset durch: ausschließlich {@code overall_status},
|
||||||
|
* {@code content_error_count}, {@code transient_error_count} und
|
||||||
|
* {@code last_failure_instant} werden zurückgesetzt. Die Versuchshistorie bleibt
|
||||||
|
* vollständig erhalten. Nach dem Reset gilt das Dokument beim nächsten
|
||||||
|
* Verarbeitungslauf als verarbeitbar.
|
||||||
|
* <p>
|
||||||
|
* <strong>Abgrenzung zu {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiResetDocumentStatusPort}:</strong>
|
||||||
|
* Der bestehende Reset-Port im {@code batchrun}-Paket löscht alle Persistenzdaten
|
||||||
|
* (Stammsatz und Versuchshistorie) vollständig. Dieser Port hier führt ausschließlich
|
||||||
|
* einen feldgenauen Update durch und lässt die Versuchshistorie unangetastet.
|
||||||
|
* <p>
|
||||||
|
* <strong>Threading:</strong> Implementierungen müssen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden können. Der Aufruf blockiert,
|
||||||
|
* bis die Operation abgeschlossen ist.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GuiHistoryResetDocumentStatusPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Status des Dokuments feldgenau zurück.
|
||||||
|
* <p>
|
||||||
|
* Folgende Felder werden aktualisiert:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code overall_status} → {@code READY_FOR_AI}</li>
|
||||||
|
* <li>{@code content_error_count} → {@code 0}</li>
|
||||||
|
* <li>{@code transient_error_count} → {@code 0}</li>
|
||||||
|
* <li>{@code last_failure_instant} → {@code null}</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen {@code .properties}-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param fingerprint der Dokumentbezeichner; darf nicht {@code null} sein
|
||||||
|
* @throws de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException
|
||||||
|
* bei technischen Datenbankfehlern
|
||||||
|
*/
|
||||||
|
void resetStatus(Path configFilePath, DocumentFingerprint fingerprint);
|
||||||
|
}
|
||||||
+1096
File diff suppressed because it is too large
Load Diff
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* GUI-Adapter für den Historien-Tab.
|
||||||
|
* <p>
|
||||||
|
* Enthält die Bridge-Interfaces {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryDetailsPort},
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort} und
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiDeleteDocumentHistoryPort}
|
||||||
|
* sowie die JavaFX-Komponente {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab}.
|
||||||
|
* <p>
|
||||||
|
* Die Bridge-Interfaces werden von Bootstrap implementiert und über
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext} in den GUI-Adapter injiziert.
|
||||||
|
* Die GUI-Komponenten kennen ausschließlich diese Interfaces –
|
||||||
|
* niemals direkt Repository- oder Use-Case-Implementierungen.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.history;
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-interner Bridge-Port fuer die Verwaltung von Modell-Preisen.
|
||||||
|
*
|
||||||
|
* <p>Dieser Port ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
|
||||||
|
* Er ist eine modul-interne Bruecke, ueber die Bootstrap die SQLite-basierte
|
||||||
|
* Verwaltung der Tabelle {@code model_price} fuer den GUI-Tab bereitstellt,
|
||||||
|
* ohne dass der GUI-Adapter direkt auf Repository-Implementierungen zugreift.
|
||||||
|
*
|
||||||
|
* <p>Der Parameter {@code configFilePath} wird benoetigt, damit die
|
||||||
|
* Bootstrap-Implementierung die SQLite-Datenbank aus der aktuell geladenen
|
||||||
|
* Konfigurationsdatei ableiten kann, ohne den Pfad global zu speichern.
|
||||||
|
*
|
||||||
|
* <p><strong>Threading:</strong> Implementierungen muessen sicher von einem
|
||||||
|
* Hintergrund-Worker-Thread aufgerufen werden koennen. Aufrufe blockieren,
|
||||||
|
* bis das Ergebnis vollstaendig vorliegt.
|
||||||
|
*/
|
||||||
|
public interface GuiModelPriceManagementPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle persistierten Modell-Preise.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
|
||||||
|
* @return Liste der Modell-Preise; nie {@code null}
|
||||||
|
*/
|
||||||
|
List<ModelPriceView> findAll(Path configFilePath);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht den Eintrag fuer (Provider, Modellname).
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
|
||||||
|
* @param provider Provider-Identifikator
|
||||||
|
* @param modelName Modellname
|
||||||
|
* @return Eintrag oder {@link Optional#empty()}
|
||||||
|
*/
|
||||||
|
Optional<ModelPriceView> findByProviderAndModelName(Path configFilePath, String provider, String modelName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistiert ein {@link ModelPriceChangeSet} atomar.
|
||||||
|
*
|
||||||
|
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
|
||||||
|
* @param changeSet Sammlung aus Upserts und Loeschungen; nicht {@code null}
|
||||||
|
*/
|
||||||
|
void saveAllChanges(Path configFilePath, ModelPriceChangeSet changeSet);
|
||||||
|
}
|
||||||
+573
@@ -0,0 +1,573 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.geometry.Insets;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ButtonType;
|
||||||
|
import javafx.scene.control.ChoiceBox;
|
||||||
|
import javafx.scene.control.Dialog;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.Tab;
|
||||||
|
import javafx.scene.control.TableCell;
|
||||||
|
import javafx.scene.control.TableColumn;
|
||||||
|
import javafx.scene.control.TableView;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.Tooltip;
|
||||||
|
import javafx.scene.layout.GridPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.Priority;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUI-Tab fuer die Verwaltung der persistierten Modell-Preise.
|
||||||
|
*
|
||||||
|
* <p>Zeigt die Tabelle {@code model_price} aufbereitet als
|
||||||
|
* {@code $/1M Tokens} an. Eintraege bekannter Provider sind editierbar;
|
||||||
|
* Eintraege unbekannter Provider werden read-only mit Tooltip dargestellt
|
||||||
|
* und koennen lediglich geloescht werden.
|
||||||
|
*
|
||||||
|
* <p>Der Tab arbeitet ausschließlich gegen den
|
||||||
|
* {@link GuiModelPriceManagementPort}. Bootstrap verdrahtet den Port mit
|
||||||
|
* einer Implementierung, die anhand der aktuell geladenen Konfigurationsdatei
|
||||||
|
* eine SQLite-Verbindung aufbaut.
|
||||||
|
*
|
||||||
|
* <p>Threading: alle DB-Operationen laufen auf einem dedizierten
|
||||||
|
* Hintergrund-Worker-Thread; UI-Updates erfolgen ueber
|
||||||
|
* {@link Platform#runLater(Runnable)}.
|
||||||
|
*/
|
||||||
|
public final class GuiModelPricesTab {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(GuiModelPricesTab.class);
|
||||||
|
|
||||||
|
private static final String TAB_TITLE = "Modell-Preise";
|
||||||
|
|
||||||
|
/** V3.3-Whitelist der unterstuetzten Provider. */
|
||||||
|
public static final List<String> SUPPORTED_PROVIDERS = List.of("openai-compatible", "claude");
|
||||||
|
|
||||||
|
private static final BigDecimal NANO_TO_USD_PER_MILLION = new BigDecimal("1000000000")
|
||||||
|
.divide(new BigDecimal("1000000"));
|
||||||
|
|
||||||
|
private final Tab tab = new Tab(TAB_TITLE);
|
||||||
|
private final TableView<EditableEntry> tableView = new TableView<>();
|
||||||
|
private final ObservableList<EditableEntry> rows = FXCollections.observableArrayList();
|
||||||
|
private final Label statusLabel = new Label();
|
||||||
|
private final Button addButton = new Button("Modell hinzufuegen");
|
||||||
|
private final Button saveButton = new Button("Speichern");
|
||||||
|
private final Button reloadButton = new Button("Neu laden");
|
||||||
|
|
||||||
|
private final GuiModelPriceManagementPort port;
|
||||||
|
private final Supplier<Optional<Path>> configPathSupplier;
|
||||||
|
private final Set<ModelPriceKey> originalKeys = new HashSet<>();
|
||||||
|
private final List<ModelPriceKey> pendingDeletions = new ArrayList<>();
|
||||||
|
|
||||||
|
private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "gui-model-prices");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt den Tab und verdrahtet die Bedienelemente.
|
||||||
|
*
|
||||||
|
* @param port Bridge-Port fuer DB-Zugriff; darf {@code null} sein (Tab zeigt dann Hinweis)
|
||||||
|
* @param configPathSupplier Liefert den aktuell geladenen Konfigurationspfad oder leer; nicht {@code null}
|
||||||
|
*/
|
||||||
|
public GuiModelPricesTab(GuiModelPriceManagementPort port,
|
||||||
|
Supplier<Optional<Path>> configPathSupplier) {
|
||||||
|
this.port = port;
|
||||||
|
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier");
|
||||||
|
tab.setClosable(false);
|
||||||
|
buildUi();
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den JavaFX-Tab-Knoten.
|
||||||
|
*
|
||||||
|
* @return der Tab; nie {@code null}
|
||||||
|
*/
|
||||||
|
public Tab tab() {
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigt den Tab ueber eine geaenderte Konfiguration.
|
||||||
|
*
|
||||||
|
* <p>Aktualisiert den Aktivierungsstatus der Buttons anhand des aktuell vom
|
||||||
|
* {@code configPathSupplier} gelieferten Pfads. Muss auf dem JavaFX Application Thread
|
||||||
|
* aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void notifyConfigurationChanged() {
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggert ein Neuladen der Tabelle aus der aktuell geladenen Konfiguration.
|
||||||
|
*/
|
||||||
|
public void reloadFromCurrentConfig() {
|
||||||
|
Optional<Path> currentPath = configPathSupplier.get();
|
||||||
|
if (port == null || currentPath.isEmpty()) {
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
rows.clear();
|
||||||
|
pendingDeletions.clear();
|
||||||
|
originalKeys.clear();
|
||||||
|
statusLabel.setText("Bitte zuerst eine Konfigurationsdatei laden.");
|
||||||
|
updateButtonStates();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path configPath = currentPath.get();
|
||||||
|
statusLabel.setText("Lade Modell-Preise ...");
|
||||||
|
workerExecutor.submit(() -> {
|
||||||
|
try {
|
||||||
|
List<ModelPriceView> views = port.findAll(configPath);
|
||||||
|
Platform.runLater(() -> applyLoadedRows(views));
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
LOG.error("Modell-Preise konnten nicht geladen werden: {}", ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() -> statusLabel.setText("Fehler beim Laden: " + ex.getMessage()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyLoadedRows(List<ModelPriceView> views) {
|
||||||
|
rows.clear();
|
||||||
|
pendingDeletions.clear();
|
||||||
|
originalKeys.clear();
|
||||||
|
for (ModelPriceView view : views) {
|
||||||
|
rows.add(EditableEntry.fromView(view));
|
||||||
|
originalKeys.add(new ModelPriceKey(view.provider(), view.modelName()));
|
||||||
|
}
|
||||||
|
statusLabel.setText("Geladen: " + views.size() + " Eintraege.");
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildUi() {
|
||||||
|
tableView.setItems(rows);
|
||||||
|
tableView.setEditable(true);
|
||||||
|
tableView.setPlaceholder(new Label("Keine Modell-Preise vorhanden."));
|
||||||
|
|
||||||
|
TableColumn<EditableEntry, String> providerCol = new TableColumn<>("Provider");
|
||||||
|
providerCol.setCellValueFactory(c -> c.getValue().providerProperty);
|
||||||
|
providerCol.setPrefWidth(150);
|
||||||
|
|
||||||
|
TableColumn<EditableEntry, String> modelCol = new TableColumn<>("Modellname");
|
||||||
|
modelCol.setCellValueFactory(c -> c.getValue().modelNameProperty);
|
||||||
|
modelCol.setPrefWidth(220);
|
||||||
|
|
||||||
|
TableColumn<EditableEntry, String> inCol = new TableColumn<>("In/1M USD");
|
||||||
|
inCol.setCellValueFactory(c -> c.getValue().inputPriceTextProperty);
|
||||||
|
inCol.setPrefWidth(120);
|
||||||
|
inCol.setCellFactory(col -> new PriceEditCell(true));
|
||||||
|
|
||||||
|
TableColumn<EditableEntry, String> outCol = new TableColumn<>("Out/1M USD");
|
||||||
|
outCol.setCellValueFactory(c -> c.getValue().outputPriceTextProperty);
|
||||||
|
outCol.setPrefWidth(120);
|
||||||
|
outCol.setCellFactory(col -> new PriceEditCell(false));
|
||||||
|
|
||||||
|
TableColumn<EditableEntry, String> currencyCol = new TableColumn<>("Waehrung");
|
||||||
|
currencyCol.setCellValueFactory(c -> c.getValue().currencyProperty);
|
||||||
|
currencyCol.setPrefWidth(80);
|
||||||
|
|
||||||
|
TableColumn<EditableEntry, String> updatedCol = new TableColumn<>("Letzte Aenderung");
|
||||||
|
updatedCol.setCellValueFactory(c -> c.getValue().updatedAtTextProperty);
|
||||||
|
updatedCol.setPrefWidth(180);
|
||||||
|
|
||||||
|
TableColumn<EditableEntry, Void> deleteCol = new TableColumn<>("Aktion");
|
||||||
|
deleteCol.setCellFactory(col -> new DeleteButtonCell());
|
||||||
|
deleteCol.setPrefWidth(80);
|
||||||
|
|
||||||
|
tableView.getColumns().setAll(List.of(providerCol, modelCol, inCol, outCol,
|
||||||
|
currencyCol, updatedCol, deleteCol));
|
||||||
|
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_LAST_COLUMN);
|
||||||
|
|
||||||
|
addButton.setOnAction(e -> openAddDialog());
|
||||||
|
saveButton.setOnAction(e -> handleSave());
|
||||||
|
reloadButton.setOnAction(e -> reloadFromCurrentConfig());
|
||||||
|
|
||||||
|
HBox buttonBar = new HBox(8, addButton, saveButton, reloadButton);
|
||||||
|
buttonBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
|
||||||
|
VBox.setVgrow(tableView, Priority.ALWAYS);
|
||||||
|
VBox content = new VBox(8, tableView, buttonBar, statusLabel);
|
||||||
|
content.setPadding(new Insets(12));
|
||||||
|
tab.setContent(content);
|
||||||
|
|
||||||
|
statusLabel.setText("Klicken Sie auf \"Neu laden\", um die aktuellen Modell-Preise anzuzeigen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateButtonStates() {
|
||||||
|
Optional<Path> path = configPathSupplier.get();
|
||||||
|
boolean active = port != null && path.isPresent();
|
||||||
|
addButton.setDisable(!active);
|
||||||
|
saveButton.setDisable(!active);
|
||||||
|
reloadButton.setDisable(!active);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openAddDialog() {
|
||||||
|
Dialog<EditableEntry> dialog = new Dialog<>();
|
||||||
|
dialog.setTitle("Modell hinzufuegen");
|
||||||
|
dialog.setHeaderText("Neuen Modell-Preis erfassen");
|
||||||
|
|
||||||
|
ChoiceBox<String> providerBox = new ChoiceBox<>(FXCollections.observableArrayList(SUPPORTED_PROVIDERS));
|
||||||
|
providerBox.getSelectionModel().selectFirst();
|
||||||
|
TextField modelField = new TextField();
|
||||||
|
TextField inputField = new TextField();
|
||||||
|
TextField outputField = new TextField();
|
||||||
|
|
||||||
|
GridPane grid = new GridPane();
|
||||||
|
grid.setHgap(8);
|
||||||
|
grid.setVgap(8);
|
||||||
|
grid.add(new Label("Provider"), 0, 0);
|
||||||
|
grid.add(providerBox, 1, 0);
|
||||||
|
grid.add(new Label("Modellname"), 0, 1);
|
||||||
|
grid.add(modelField, 1, 1);
|
||||||
|
grid.add(new Label("In/1M USD"), 0, 2);
|
||||||
|
grid.add(inputField, 1, 2);
|
||||||
|
grid.add(new Label("Out/1M USD"), 0, 3);
|
||||||
|
grid.add(outputField, 1, 3);
|
||||||
|
|
||||||
|
dialog.getDialogPane().setContent(grid);
|
||||||
|
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
|
||||||
|
dialog.setResultConverter(button -> {
|
||||||
|
if (button != ButtonType.OK) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String provider = providerBox.getValue();
|
||||||
|
String modelName = modelField.getText() != null ? modelField.getText().trim() : "";
|
||||||
|
if (provider == null || modelName.isEmpty()) {
|
||||||
|
showError("Provider und Modellname sind Pflichtfelder.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
long inputNano = parseUsdPerMillionToNano(inputField.getText());
|
||||||
|
long outputNano = parseUsdPerMillionToNano(outputField.getText());
|
||||||
|
EditableEntry entry = new EditableEntry(provider, modelName,
|
||||||
|
inputNano, outputNano, "USD", null, false, true);
|
||||||
|
return entry;
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
showError(ex.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Optional<EditableEntry> result = dialog.showAndWait();
|
||||||
|
result.ifPresent(entry -> {
|
||||||
|
for (EditableEntry existing : rows) {
|
||||||
|
if (existing.providerProperty.get().equals(entry.providerProperty.get())
|
||||||
|
&& existing.modelNameProperty.get().equals(entry.modelNameProperty.get())) {
|
||||||
|
showError("Eintrag fuer Provider \"" + entry.providerProperty.get()
|
||||||
|
+ "\" und Modell \"" + entry.modelNameProperty.get()
|
||||||
|
+ "\" existiert bereits.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.add(entry);
|
||||||
|
statusLabel.setText("Neuer Eintrag vorgemerkt; bitte speichern.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleSave() {
|
||||||
|
Optional<Path> currentPath = configPathSupplier.get();
|
||||||
|
if (port == null || currentPath.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ModelPriceChangeSet changeSet;
|
||||||
|
try {
|
||||||
|
changeSet = buildChangeSet();
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
showError(ex.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (changeSet.isEmpty()) {
|
||||||
|
statusLabel.setText("Keine Aenderungen zu speichern.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path configPath = currentPath.get();
|
||||||
|
statusLabel.setText("Speichere ...");
|
||||||
|
saveButton.setDisable(true);
|
||||||
|
workerExecutor.submit(() -> {
|
||||||
|
try {
|
||||||
|
port.saveAllChanges(configPath, changeSet);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusLabel.setText("Aenderungen gespeichert.");
|
||||||
|
reloadFromCurrentConfig();
|
||||||
|
});
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
LOG.error("Modell-Preis-Speichern fehlgeschlagen: {}", ex.getMessage(), ex);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
statusLabel.setText("Fehler beim Speichern: " + ex.getMessage());
|
||||||
|
saveButton.setDisable(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModelPriceChangeSet buildChangeSet() {
|
||||||
|
List<ModelPriceEntry> upserts = new ArrayList<>();
|
||||||
|
Instant placeholder = Instant.now();
|
||||||
|
for (EditableEntry row : rows) {
|
||||||
|
if (!row.editable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!row.dirty && originalKeys.contains(
|
||||||
|
new ModelPriceKey(row.providerProperty.get(), row.modelNameProperty.get()))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
upserts.add(new ModelPriceEntry(
|
||||||
|
row.providerProperty.get(),
|
||||||
|
row.modelNameProperty.get(),
|
||||||
|
row.inputPriceNanoUsd,
|
||||||
|
row.outputPriceNanoUsd,
|
||||||
|
"USD",
|
||||||
|
placeholder));
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
throw new IllegalArgumentException("Eintrag (" + row.providerProperty.get()
|
||||||
|
+ ", " + row.modelNameProperty.get() + ") ungueltig: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ModelPriceChangeSet(upserts, List.copyOf(pendingDeletions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showError(String message) {
|
||||||
|
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
|
||||||
|
alert.setHeaderText("Eingabefehler");
|
||||||
|
alert.showAndWait();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert eine $/1M-Tokens-Eingabe in Nano-USD/Token.
|
||||||
|
*
|
||||||
|
* <p>Akzeptiert Komma oder Punkt als Dezimaltrenner. Maximal sechs
|
||||||
|
* Nachkommastellen sind erlaubt; mehr fuehrt zur
|
||||||
|
* {@link IllegalArgumentException}. Negative Werte und Nicht-Numerisches
|
||||||
|
* werden ebenfalls abgewiesen.
|
||||||
|
*
|
||||||
|
* @param raw Eingabetext
|
||||||
|
* @return umgerechneter Nano-USD-Wert
|
||||||
|
* @throws IllegalArgumentException bei ungueltiger Eingabe
|
||||||
|
*/
|
||||||
|
static long parseUsdPerMillionToNano(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Preis darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
String normalized = raw.trim().replace(',', '.');
|
||||||
|
BigDecimal value;
|
||||||
|
try {
|
||||||
|
value = new BigDecimal(normalized);
|
||||||
|
} catch (NumberFormatException nfe) {
|
||||||
|
throw new IllegalArgumentException("Preis ist nicht numerisch: " + raw);
|
||||||
|
}
|
||||||
|
if (value.signum() < 0) {
|
||||||
|
throw new IllegalArgumentException("Preis darf nicht negativ sein.");
|
||||||
|
}
|
||||||
|
if (value.scale() > 6) {
|
||||||
|
throw new IllegalArgumentException("Maximal 6 Nachkommastellen erlaubt.");
|
||||||
|
}
|
||||||
|
BigDecimal nanoPerToken = value.multiply(BigDecimal.valueOf(1000L))
|
||||||
|
.setScale(0, RoundingMode.HALF_UP);
|
||||||
|
long nanoLong = nanoPerToken.longValueExact();
|
||||||
|
if (nanoLong > ModelPriceEntry.MAX_PRICE_PER_TOKEN_NANO_USD) {
|
||||||
|
throw new IllegalArgumentException("Preis ueberschreitet Maximum.");
|
||||||
|
}
|
||||||
|
return nanoLong;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert einen Nano-USD-Wert als $/1M-Tokens-Text.
|
||||||
|
*
|
||||||
|
* @param nanoPerToken Nano-USD pro Token
|
||||||
|
* @return Formatierter Text mit bis zu sechs Nachkommastellen
|
||||||
|
*/
|
||||||
|
static String formatNanoAsUsdPerMillion(long nanoPerToken) {
|
||||||
|
BigDecimal usdPerMillion = BigDecimal.valueOf(nanoPerToken)
|
||||||
|
.multiply(NANO_TO_USD_PER_MILLION)
|
||||||
|
.divide(new BigDecimal("1000000000"), 6, RoundingMode.HALF_UP)
|
||||||
|
.stripTrailingZeros();
|
||||||
|
return usdPerMillion.toPlainString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable Tabellenzeile. Kapselt View-Felder als Properties und einen
|
||||||
|
* Dirty-Flag fuer den ChangeSet-Bau.
|
||||||
|
*/
|
||||||
|
private static final class EditableEntry {
|
||||||
|
final SimpleStringProperty providerProperty;
|
||||||
|
final SimpleStringProperty modelNameProperty;
|
||||||
|
final SimpleStringProperty inputPriceTextProperty;
|
||||||
|
final SimpleStringProperty outputPriceTextProperty;
|
||||||
|
final SimpleStringProperty currencyProperty;
|
||||||
|
final SimpleStringProperty updatedAtTextProperty;
|
||||||
|
final SimpleObjectProperty<Boolean> invalidUpdatedAtProperty;
|
||||||
|
long inputPriceNanoUsd;
|
||||||
|
long outputPriceNanoUsd;
|
||||||
|
boolean editable;
|
||||||
|
boolean dirty;
|
||||||
|
|
||||||
|
EditableEntry(String provider, String modelName,
|
||||||
|
long inputNano, long outputNano, String currency,
|
||||||
|
Instant updatedAt, boolean invalidUpdatedAt, boolean editable) {
|
||||||
|
this.providerProperty = new SimpleStringProperty(provider);
|
||||||
|
this.modelNameProperty = new SimpleStringProperty(modelName);
|
||||||
|
this.inputPriceNanoUsd = inputNano;
|
||||||
|
this.outputPriceNanoUsd = outputNano;
|
||||||
|
this.inputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(inputNano));
|
||||||
|
this.outputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(outputNano));
|
||||||
|
this.currencyProperty = new SimpleStringProperty(currency);
|
||||||
|
this.updatedAtTextProperty = new SimpleStringProperty(
|
||||||
|
invalidUpdatedAt ? "ungueltig"
|
||||||
|
: updatedAt == null ? "" : DateTimeFormatter.ISO_INSTANT.format(updatedAt));
|
||||||
|
this.invalidUpdatedAtProperty = new SimpleObjectProperty<>(invalidUpdatedAt);
|
||||||
|
this.editable = editable;
|
||||||
|
this.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static EditableEntry fromView(ModelPriceView view) {
|
||||||
|
boolean editable = SUPPORTED_PROVIDERS.contains(view.provider());
|
||||||
|
EditableEntry entry = new EditableEntry(
|
||||||
|
view.provider(), view.modelName(),
|
||||||
|
view.priceInputPerTokenNanoUsd(), view.priceOutputPerTokenNanoUsd(),
|
||||||
|
view.currency(), view.updatedAt(), view.invalidUpdatedAt(), editable);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editierbare Zelle fuer Input-/Output-Preisfelder.
|
||||||
|
*/
|
||||||
|
private final class PriceEditCell extends TableCell<EditableEntry, String> {
|
||||||
|
private final TextField textField = new TextField();
|
||||||
|
private final boolean isInputColumn;
|
||||||
|
|
||||||
|
PriceEditCell(boolean isInputColumn) {
|
||||||
|
this.isInputColumn = isInputColumn;
|
||||||
|
textField.focusedProperty().addListener((obs, was, focused) -> {
|
||||||
|
if (!focused) {
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
textField.setOnAction(e -> commit());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void commit() {
|
||||||
|
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
|
||||||
|
if (row == null || !row.editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String text = textField.getText();
|
||||||
|
try {
|
||||||
|
long nano = parseUsdPerMillionToNano(text);
|
||||||
|
if (isInputColumn) {
|
||||||
|
if (row.inputPriceNanoUsd != nano) {
|
||||||
|
row.inputPriceNanoUsd = nano;
|
||||||
|
row.dirty = true;
|
||||||
|
}
|
||||||
|
row.inputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
|
||||||
|
} else {
|
||||||
|
if (row.outputPriceNanoUsd != nano) {
|
||||||
|
row.outputPriceNanoUsd = nano;
|
||||||
|
row.dirty = true;
|
||||||
|
}
|
||||||
|
row.outputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
showError(ex.getMessage());
|
||||||
|
String revert = isInputColumn
|
||||||
|
? formatNanoAsUsdPerMillion(row.inputPriceNanoUsd)
|
||||||
|
: formatNanoAsUsdPerMillion(row.outputPriceNanoUsd);
|
||||||
|
textField.setText(revert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void updateItem(String item, boolean empty) {
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
if (empty || item == null) {
|
||||||
|
setText(null);
|
||||||
|
setGraphic(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
|
||||||
|
if (row != null && !row.editable) {
|
||||||
|
setText(item);
|
||||||
|
setGraphic(null);
|
||||||
|
setTooltip(new Tooltip("Unbekannter Provider – Bearbeitung in V3.3 nicht unterstuetzt."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
textField.setText(item);
|
||||||
|
setText(null);
|
||||||
|
setGraphic(textField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loesch-Button-Spalte mit Bestaetigungsdialog.
|
||||||
|
*/
|
||||||
|
private final class DeleteButtonCell extends TableCell<EditableEntry, Void> {
|
||||||
|
private final Button button = new Button("Loeschen");
|
||||||
|
|
||||||
|
DeleteButtonCell() {
|
||||||
|
button.setOnAction(e -> {
|
||||||
|
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
|
||||||
|
if (row == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String message = String.format(Locale.GERMAN,
|
||||||
|
"Eintrag fuer Provider \"%s\" und Modell \"%s\" wirklich loeschen?",
|
||||||
|
row.providerProperty.get(), row.modelNameProperty.get());
|
||||||
|
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, message, ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
alert.setHeaderText("Loeschen bestaetigen");
|
||||||
|
alert.showAndWait().ifPresent(button -> {
|
||||||
|
if (button == ButtonType.OK) {
|
||||||
|
ModelPriceKey key = new ModelPriceKey(
|
||||||
|
row.providerProperty.get(), row.modelNameProperty.get());
|
||||||
|
if (originalKeys.contains(key)) {
|
||||||
|
pendingDeletions.add(key);
|
||||||
|
}
|
||||||
|
rows.remove(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void updateItem(Void item, boolean empty) {
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
if (empty) {
|
||||||
|
setGraphic(null);
|
||||||
|
} else {
|
||||||
|
setGraphic(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* GUI-Bestandteile fuer die Verwaltung der persistierten Modell-Preise.
|
||||||
|
*
|
||||||
|
* <p>Enthaelt den Bridge-Port {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices
|
||||||
|
* .GuiModelPriceManagementPort} und den zugehoerigen Tab. Der Port wird von
|
||||||
|
* Bootstrap mit einer Lambda-Implementierung gefuellt, die anhand der aktuell
|
||||||
|
* geladenen Konfigurationsdatei eine SQLite-Repository-Instanz aufbaut.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 465 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user