Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -56,8 +56,8 @@ 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)
|
||||||
- das Shade-JAR bleibt das primäre Distributionsartefakt
|
- 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
|
- 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
|
||||||
@@ -77,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**
|
||||||
@@ -151,6 +170,8 @@ Der Basisstand V2.0 (JavaFX-GUI als Standardstart, Konfigurationseditor, technis
|
|||||||
|
|
||||||
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.
|
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.
|
Die fachliche Kernverarbeitung des PDF-Umbenenners bleibt unverändert.
|
||||||
|
|
||||||
## Statussemantik
|
## Statussemantik
|
||||||
@@ -240,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
|
||||||
@@ -294,6 +322,8 @@ Verbindlich zweckmäßige Parameter:
|
|||||||
- `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:
|
||||||
|
|
||||||
@@ -332,7 +362,7 @@ Verbindlicher Ablauf:
|
|||||||
- 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
+10
@@ -87,6 +87,16 @@ EOF
|
|||||||
}
|
}
|
||||||
} // stage: Maven Build
|
} // 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') {
|
stage('Publish PIT Coverage') {
|
||||||
steps {
|
steps {
|
||||||
recordCoverage(
|
recordCoverage(
|
||||||
|
|||||||
+148
-10
@@ -63,7 +63,7 @@ mehr, startet die GUI ohne Fehlermeldung mit dem Willkommenstext.
|
|||||||
|
|
||||||
### Umfang der GUI
|
### Umfang der GUI
|
||||||
|
|
||||||
Die GUI enthält drei Tabs:
|
Die GUI enthält fünf Tabs:
|
||||||
|
|
||||||
- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für
|
- **Tab „Konfiguration"** – Editor, Validierungs- und technische Testoberfläche für
|
||||||
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
|
die `.properties`-Datei (Erreichbarkeit des Providers, Pfade, SQLite-Datei,
|
||||||
@@ -75,6 +75,14 @@ Die GUI enthält drei Tabs:
|
|||||||
ungespeicherte Änderungen im Editor fließen nicht in den Lauf ein. Ein **Soft-Stop**
|
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.
|
ü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.
|
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
|
- **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).
|
aus der Oberfläche. Bearbeitungen erzeugen einen Dirty-State (Asterisk im Tab-Titel).
|
||||||
Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`).
|
Speichern erfolgt **atomar** (Temp-Datei im selben Verzeichnis + `ATOMIC_MOVE`).
|
||||||
@@ -88,6 +96,37 @@ kann weiterhin für automatisierte Läufe genutzt werden. Pro Anwendungsinstanz
|
|||||||
ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf
|
ein Verarbeitungslauf gleichzeitig zulässig; ein gleichzeitiger externer headless Lauf
|
||||||
wird jedoch nicht technisch erkannt oder blockiert.
|
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
|
||||||
@@ -229,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
|
||||||
|
|
||||||
@@ -425,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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -435,11 +496,50 @@ 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
|
## Build und Packaging
|
||||||
@@ -550,13 +650,51 @@ Installationsverzeichnis ab. **Der Betreiber muss diese Beispieldatei manuell na
|
|||||||
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
|
Windows-SmartScreen-Warnung, die durch „Weitere Informationen → Trotzdem ausführen"
|
||||||
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
|
bestätigt werden muss. Code-Signing ist für spätere Ausbaustufen vorgesehen.
|
||||||
|
|
||||||
**Empfehlung für Prompt- und Konfigurationspfade im MSI-Betrieb:**
|
**Empfehlung für Pfade im MSI-Betrieb:**
|
||||||
|
|
||||||
Für den MSI-Betrieb (Startmenü, Task Scheduler) wird **empfohlen**,
|
Für den MSI-Betrieb (Startmenü, Task Scheduler) müssen alle Dateipfade als **absolute Pfade**
|
||||||
`prompt.template.file` und `sqlite.file` als **absolute Pfade** zu konfigurieren
|
konfiguriert werden. Relative Pfade werden relativ zum Installationsverzeichnis
|
||||||
oder auf `%APPDATA%`- bzw. `%ProgramData%`-Verzeichnisse zu zeigen.
|
`C:\Program Files\PDF KI Renamer\` aufgelöst, das **schreibgeschützt** ist. Dadurch
|
||||||
Relative Pfade beziehen sich auf das Arbeitsverzeichnis, das je nach Startart variiert
|
schlagen Schreibversuche (Logs, SQLite-Datenbank, Lock-Datei) ohne Fehlermeldung fehl.
|
||||||
(siehe Abschnitt „Prompt-Konfiguration").
|
|
||||||
|
> **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
|
### MSI-Release-Checkliste
|
||||||
|
|
||||||
@@ -676,7 +814,7 @@ Die Bedienung der GUI ist in [`gui-bedienanleitung.md`](gui-bedienanleitung.md)
|
|||||||
- Keine eingebaute OCR-Funktion
|
- Keine eingebaute OCR-Funktion
|
||||||
- Kein Web-UI, keine REST-API
|
- Kein Web-UI, keine REST-API
|
||||||
- Die GUI ermöglicht Konfiguration, Validierung, technische Diagnose und die Ausführung von Verarbeitungsläufen mit integrierter PDF-Vorschau und editierbarem Dateiname
|
- 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 – der Batch-Betrieb wird extern angestoßen (z. B. Windows Task Scheduler, `--headless`)
|
- 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
|
- Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
## Geprüfter Stand
|
## Geprüfter Stand
|
||||||
|
|
||||||
- Git-Branch: `main`
|
- Git-Branch: `main`
|
||||||
- Versionsnummer: `3.0.x`
|
- Versionsnummer: `3.0.238`
|
||||||
*(Konkrete Build-Nummer wird beim Release-Build von Jenkins als Suffix gesetzt –
|
- MSI-Datei: `PDF-KI-Renamer-3.0.238.msi`
|
||||||
Marcus trägt sie hier nach dem finalen Release-Build ein.)*
|
- Freigabedatum: 2026-05-05
|
||||||
- Freigabedatum: 2026-05-03
|
- **Status:** freigegeben
|
||||||
- **Status:** vorläufige Implementierungs-Freigabe;
|
|
||||||
finale Release-Freigabe nach abgeschlossener MSI-Testmatrix und manuellem GUI-Produkttest
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.297`
|
||||||
|
- 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.
|
||||||
+244
-33
@@ -8,18 +8,20 @@ verwalten und technisch prüfen möchten.
|
|||||||
|
|
||||||
## 1. Zweck und Scope der GUI
|
## 1. Zweck und Scope der GUI
|
||||||
|
|
||||||
Die GUI gliedert sich in vier feste Tabs:
|
Die GUI gliedert sich in fünf feste Tabs:
|
||||||
|
|
||||||
- **Tab 1 „Konfiguration"** – Editor, Validierungsoberfläche und technische
|
- **Tab 1 „Konfiguration"** – Editor, Validierungsoberfläche und technische
|
||||||
Test-/Diagnoseoberfläche für die `.properties`-Datei.
|
Test-/Diagnoseoberfläche für die `.properties`-Datei.
|
||||||
- **Tab 2 „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit
|
- **Tab 2 „Verarbeitungslauf"** – Start eines Batch-Laufs aus der GUI mit
|
||||||
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
|
Live-Fortschritt, Ergebnisliste und KI-Begründung je Dokument (siehe Abschnitt 13).
|
||||||
- **Tab 3 „Verlauf"** – Ansicht aller bisher verarbeiteten Dokumente mit Status
|
- **Tab 3 „Scheduler"** – Optionaler automatischer Scheduler für periodische
|
||||||
und Verarbeitungsdetails aus der SQLite-Datenbank (siehe Abschnitt 16).
|
Verarbeitungsläufe (siehe Abschnitt 14).
|
||||||
- **Tab 4 „Prompt"** – Editor zum Lesen, Bearbeiten und Speichern der
|
- **Tab 4 „Verlauf"** – Ansicht aller bisher verarbeiteten Dokumente mit Status
|
||||||
konfigurierten KI-Prompt-Datei (siehe Abschnitt 17).
|
und Verarbeitungsdetails aus der SQLite-Datenbank (siehe Abschnitt 17).
|
||||||
|
- **Tab 5 „Prompt"** – Editor zum Lesen, Bearbeiten und Speichern der
|
||||||
|
konfigurierten KI-Prompt-Datei (siehe Abschnitt 18).
|
||||||
|
|
||||||
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 18).
|
Am unteren Fensterrand ist permanent eine **Statuszeile** sichtbar (siehe Abschnitt 19).
|
||||||
|
|
||||||
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
|
Für unbeaufsichtigte, geplante Läufe (z. B. Windows Task Scheduler) bleibt
|
||||||
`--headless` der empfohlene Weg.
|
`--headless` der empfohlene Weg.
|
||||||
@@ -337,13 +339,23 @@ vorbelegt.
|
|||||||
|
|
||||||
## 8. Dirty-State und Schutzdialoge
|
## 8. Dirty-State und Schutzdialoge
|
||||||
|
|
||||||
|
### Konfigurations-Tab
|
||||||
|
|
||||||
Sobald eine geladene oder neu erzeugte Konfiguration bearbeitet wird, gilt der
|
Sobald eine geladene oder neu erzeugte Konfiguration bearbeitet wird, gilt der
|
||||||
Editor als „dirty" (ungespeicherte Änderungen). Zwei visuelle Markierungen
|
Editor als „dirty" (ungespeicherte Änderungen). Drei visuelle Markierungen
|
||||||
zeigen diesen Zustand an:
|
zeigen diesen Zustand an:
|
||||||
|
|
||||||
|
- Ein **`*`**-Präfix im **Tab-Titel**: `* Konfiguration`
|
||||||
- Ein **`*`**-Präfix im Fenstertitel
|
- Ein **`*`**-Präfix im Fenstertitel
|
||||||
- Ein kleines **„geändert"**-Label im Header
|
- Ein kleines **„geändert"**-Label im Header
|
||||||
|
|
||||||
|
Das Dirty-Flag wird über einen **Baseline-Snapshot** ermittelt: Beim Laden einer
|
||||||
|
Konfiguration wird ein Snapshot des geladenen Zustands gespeichert. Erst wenn
|
||||||
|
der aktuelle Formularinhalt vom Snapshot abweicht, erscheint der Dirty-Indikator.
|
||||||
|
Programmgesteuertes Laden und Normalisieren von Feldinhalten lösen keinen
|
||||||
|
Dirty-State aus. Auch ein DB-Pfad-Wechsel über „Neue Datenbank anlegen..."
|
||||||
|
(Abschnitt 17a) versetzt den Konfigurations-Tab in den Dirty-State.
|
||||||
|
|
||||||
Vor den Aktionen „Neu", „Öffnen" und beim Schließen des Fensters prüft die GUI,
|
Vor den Aktionen „Neu", „Öffnen" und beim Schließen des Fensters prüft die GUI,
|
||||||
ob ungespeicherte Änderungen vorhanden sind. Ist dies der Fall, erscheint ein
|
ob ungespeicherte Änderungen vorhanden sind. Ist dies der Fall, erscheint ein
|
||||||
Schutzdialog mit drei Optionen:
|
Schutzdialog mit drei Optionen:
|
||||||
@@ -354,6 +366,12 @@ Schutzdialog mit drei Optionen:
|
|||||||
| **Verwerfen** | Verwirft die Änderungen und führt die Aktion aus |
|
| **Verwerfen** | Verwirft die Änderungen und führt die Aktion aus |
|
||||||
| **Abbrechen** | Bricht die Aktion ab; die Änderungen bleiben erhalten |
|
| **Abbrechen** | Bricht die Aktion ab; die Änderungen bleiben erhalten |
|
||||||
|
|
||||||
|
### Prompt-Tab
|
||||||
|
|
||||||
|
Der Prompt-Tab zeigt ebenfalls ein Asterisk im Tab-Titel (`Prompt *`), sobald der
|
||||||
|
TextArea-Inhalt vom gespeicherten Stand abweicht. Das Verhalten ist identisch zum
|
||||||
|
Konfigurations-Tab (Schutzdialog, Reset nach Speichern).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. `.bak`-Sicherung beim Überschreiben und Legacy-Migration
|
## 9. `.bak`-Sicherung beim Überschreiben und Legacy-Migration
|
||||||
@@ -488,7 +506,7 @@ in den Lauf ein. Vor dem Start muss die Konfiguration daher gespeichert sein.
|
|||||||
|
|
||||||
Farbe ist niemals das einzige Unterscheidungsmerkmal – Icon und Tooltip beschreiben
|
Farbe ist niemals das einzige Unterscheidungsmerkmal – Icon und Tooltip beschreiben
|
||||||
den Status auch ohne Farbwahrnehmung eindeutig. Die vollständige Status-Mapping-Tabelle
|
den Status auch ohne Farbwahrnehmung eindeutig. Die vollständige Status-Mapping-Tabelle
|
||||||
mit Tooltips ist in Abschnitt 19 beschrieben.
|
mit Tooltips ist in Abschnitt 20 beschrieben.
|
||||||
|
|
||||||
- Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge
|
- Ein Klick auf eine Zeile öffnet den Detailbereich rechts. Für `FAILED_*`-Einträge
|
||||||
zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des
|
zeigt der Detailbereich eine übersetzte Fehlermeldung (Präfix `⚠`) anstelle des
|
||||||
@@ -619,10 +637,31 @@ Das Panel enthält drei Bereiche:
|
|||||||
- **Seitennavigation:** Über die Schaltflächen **„◀"** und **„▶"** (oder das Mausrad)
|
- **Seitennavigation:** Über die Schaltflächen **„◀"** und **„▶"** (oder das Mausrad)
|
||||||
kann seitenweise geblättert werden. Die aktuelle Seitenzahl und Gesamtseitenzahl
|
kann seitenweise geblättert werden. Die aktuelle Seitenzahl und Gesamtseitenzahl
|
||||||
werden angezeigt.
|
werden angezeigt.
|
||||||
- **Fit-to-view:** Die Seite wird automatisch an die verfügbare Fläche angepasst
|
- **Fit-to-Width:** Nach dem Laden wird die Seite automatisch an die verfügbare Breite
|
||||||
(preserveRatio=true). Keine Scrollbalken, keine manuelle Zoom-Einstellung.
|
angepasst (preserveRatio=true).
|
||||||
- Das Rendering erfolgt direkt über Apache PDFBox bei 120 DPI.
|
- Das Rendering erfolgt direkt über Apache PDFBox bei 120 DPI.
|
||||||
|
|
||||||
|
#### Zoom per Mausrad (Strg+Mausrad)
|
||||||
|
|
||||||
|
- **Strg + Mausrad nach oben/unten** zoomt die Vorschau herein bzw. heraus.
|
||||||
|
- Zoombereich: **10 % bis 500 %**, ca. 10 % je Mausrad-Rastpunkt.
|
||||||
|
- Nach dem ersten manuellen 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 betätigt wird.
|
||||||
|
- Beim Laden eines neuen PDF wird der Zoom auf Fit-to-Width zurückgesetzt.
|
||||||
|
- Beim Zoomen bleibt die sichtbare Viewport-Mitte möglichst stabil.
|
||||||
|
- Trackpad-Gesten (sehr kleine Delta-Werte) werden intern akkumuliert, bis ein
|
||||||
|
vollständiger Zoomschritt erreicht ist.
|
||||||
|
- **Ohne Strg:** Mausrad scrollt die Seite normal (kein Zoom).
|
||||||
|
- ScrollEvents mit gedrückter Strg-Taste werden immer konsumiert, sodass kein
|
||||||
|
paralleles Scrollen im Hintergrund stattfindet.
|
||||||
|
|
||||||
|
#### Grab & Pan (Handcursor im Zoom-Modus)
|
||||||
|
|
||||||
|
Im vergrößerten Zustand (Zoom über Fit-to-Width) wechselt der Mauszeiger über
|
||||||
|
der Vorschau auf einen **Handcursor**. Durch Klicken und Ziehen (Drag) kann die
|
||||||
|
Ansicht verschoben werden. Im Fit-to-Width-Modus ist Pan nicht aktiv.
|
||||||
|
|
||||||
### KI-Begründung und Fehlertext
|
### KI-Begründung und Fehlertext
|
||||||
|
|
||||||
Der mittlere Bereich zeigt das KI-Reasoning des ausgewählten Eintrags.
|
Der mittlere Bereich zeigt das KI-Reasoning des ausgewählten Eintrags.
|
||||||
@@ -710,7 +749,88 @@ nicht gezählt – sie treten nach Laufabschluss nicht mehr auf.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Bekannte Einschränkungen
|
## 14. Tab „Scheduler" (automatische Verarbeitungsläufe)
|
||||||
|
|
||||||
|
Der dritte Tab **„Scheduler"** ermöglicht den Betrieb eines optionalen, periodisch
|
||||||
|
ausgeführten automatischen Schedulers. Er startet Verarbeitungsläufe in einem
|
||||||
|
konfigurierten Intervall, ohne dass ein manueller Start erforderlich ist.
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
|
||||||
|
Damit der Scheduler-Tab funktioniert, muss in der **gespeicherten** Konfigurationsdatei
|
||||||
|
`scheduler.enabled=true` und ein gültiges `scheduler.interval.seconds` (Integer >= 30)
|
||||||
|
eingetragen sein. Ungültige oder fehlende Werte werden im Tab als Fehler gemeldet; der
|
||||||
|
Scheduler-Start ist in diesem Fall nicht möglich.
|
||||||
|
|
||||||
|
### Start und Stop
|
||||||
|
|
||||||
|
- **„Scheduler starten"** – Aktiviert den Scheduler. Der erste Lauf beginnt
|
||||||
|
**unmittelbar** nach dem Start (kein initiales Warten auf das Intervall).
|
||||||
|
- **„Scheduler stoppen"** – Stoppt den Scheduler. Ein laufender Verarbeitungslauf wird
|
||||||
|
als Soft-Stop behandelt: die aktuell bearbeitete Datei wird fertig verarbeitet,
|
||||||
|
danach hält der Scheduler an.
|
||||||
|
|
||||||
|
Beide Buttons wechseln je nach Zustand ihre Sichtbarkeit: Nur der zum aktuellen
|
||||||
|
Zustand passende Button ist aktiv.
|
||||||
|
|
||||||
|
### Statusanzeige
|
||||||
|
|
||||||
|
Der Tab zeigt den aktuellen Scheduler-Zustand in Echtzeit (1-Sekunden-Takt):
|
||||||
|
|
||||||
|
| Zustand | Anzeige |
|
||||||
|
|---------|---------|
|
||||||
|
| `STOPPED` | Scheduler gestoppt |
|
||||||
|
| `STARTING` | Scheduler wird gestartet … |
|
||||||
|
| `RUNNING_IDLE` | Scheduler läuft – nächster Lauf in `HH:MM:SS` |
|
||||||
|
| `RUNNING_BATCH_ACTIVE` | Scheduler läuft – Verarbeitungslauf aktiv |
|
||||||
|
| `STOPPING_BATCH_ACTIVE` | Scheduler wird gestoppt – Lauf läuft noch … |
|
||||||
|
|
||||||
|
Im Zustand `RUNNING_IDLE` zeigt der Tab einen Countdown bis zum nächsten automatischen
|
||||||
|
Verarbeitungslauf.
|
||||||
|
|
||||||
|
### Informationen zum letzten Lauf
|
||||||
|
|
||||||
|
Der Tab zeigt:
|
||||||
|
- **Letzter Lauf beendet:** Zeitpunkt des letzten abgeschlossenen Verarbeitungslaufs
|
||||||
|
(oder „–" wenn noch kein Lauf stattfand).
|
||||||
|
- **Zusammenfassung:** Anzahl erfolgreich, wiederholt, fehlgeschlagen und übersprungen
|
||||||
|
des letzten Laufs (falls verfügbar).
|
||||||
|
- **Letzter Fehler:** Fehlermeldung des letzten nicht erfolgreichen Scheduler-Laufs,
|
||||||
|
sofern vorhanden.
|
||||||
|
|
||||||
|
### Autostart-Fehler
|
||||||
|
|
||||||
|
Ist `scheduler.enabled=true` in der Konfiguration, versucht die GUI den Scheduler
|
||||||
|
beim Start automatisch zu aktivieren. Schlägt dies fehl (z. B. ungültige Konfiguration,
|
||||||
|
Intervall < 30 Sekunden), wird der Fehler im Tab angezeigt. Der Benutzer kann dann die
|
||||||
|
Konfiguration korrigieren und den Scheduler manuell starten.
|
||||||
|
|
||||||
|
### Warum sind manuelle Läufe während eines aktiven Schedulers gesperrt?
|
||||||
|
|
||||||
|
Manuelle Läufe (Tab „Verarbeitungslauf") sind während eines aktiven Schedulers
|
||||||
|
deaktiviert. Dadurch werden parallele Läufe auf dieselbe Datenmenge vermieden, die
|
||||||
|
zu inkonsistenten Datenbankzuständen führen könnten. Der Start-Button im Tab
|
||||||
|
„Verarbeitungslauf" ist während eines aktiven Schedulers deaktiviert und zeigt einen
|
||||||
|
erklärenden Tooltip.
|
||||||
|
|
||||||
|
### Warum ist Tab 1 „Konfiguration" während eines aktiven Schedulers gesperrt?
|
||||||
|
|
||||||
|
Um sicherzustellen, dass der Scheduler mit einer konsistenten Konfiguration läuft,
|
||||||
|
ist der Konfigurations-Editor während eines aktiven Schedulers gesperrt. Ein
|
||||||
|
Hinweisbanner erklärt die Sperre. Konfigurationsänderungen können nach dem Stoppen
|
||||||
|
des Schedulers vorgenommen werden.
|
||||||
|
|
||||||
|
### Schließen der Anwendung
|
||||||
|
|
||||||
|
Versucht der Benutzer das Fenster zu schließen oder die Anwendung über das
|
||||||
|
Tray-Menü zu beenden, während der Scheduler aktiv ist oder ein Lauf läuft, erscheint
|
||||||
|
ein Informationsdialog mit dem Hinweis, den Scheduler zunächst zu stoppen bzw. den
|
||||||
|
laufenden Verarbeitungslauf abzuwarten. Das Schließen wird blockiert, bis der Scheduler
|
||||||
|
gestoppt und kein Lauf mehr aktiv ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Bekannte Einschränkungen
|
||||||
|
|
||||||
| Einschränkung | Erläuterung |
|
| Einschränkung | Erläuterung |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -718,13 +838,13 @@ nicht gezählt – sie treten nach Laufabschluss nicht mehr auf.
|
|||||||
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
|
| Keine Erkennung externer Änderungen | Wird die `.properties`-Datei während einer GUI-Sitzung von außen geändert, erkennt die GUI dies nicht. Die GUI arbeitet weiterhin auf dem zuletzt geladenen Stand |
|
||||||
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
|
| Keine Koordination mit parallelen headless Läufen | Ein gleichzeitiger externer headless Lauf wird nicht technisch geblockt. Schreibkonflikte sind nicht ausgeschlossen, wenn dieselbe `.properties`-Datei parallel genutzt wird |
|
||||||
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
|
| GUI nur für Windows | Die GUI wird offiziell nur unter Windows unterstützt; der headless Betrieb ist für Windows Server geeignet |
|
||||||
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. Die dauerhaften Ergebnisse sind im Verlauf-Tab (Abschnitt 16) einsehbar. |
|
| Ergebnisliste nicht persistent | Die Ergebnisliste im Verarbeitungslauf-Tab existiert nur für den aktuellen Programmstart; nach Neustart ist die Liste leer. Die dauerhaften Ergebnisse sind im Verlauf-Tab (Abschnitt 17) einsehbar. |
|
||||||
| Einzelinstanz-Schutz | Wird die Anwendung ein zweites Mal gestartet, während bereits eine Instanz läuft (auch wenn diese im System-Tray minimiert ist), beendet sich die neue Instanz sofort ohne Hinweisfenster |
|
| Einzelinstanz-Schutz | Wird die Anwendung ein zweites Mal gestartet, während bereits eine Instanz läuft (auch wenn diese im System-Tray minimiert ist), beendet sich die neue Instanz sofort ohne Hinweisfenster |
|
||||||
| Prompt-Editor: kein automatisches Reload | Wird die Prompt-Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht. Beim Speichern gilt Last-write-wins. |
|
| Prompt-Editor: kein automatisches Reload | Wird die Prompt-Datei während einer Bearbeitung extern geändert, erkennt die GUI dies nicht. Beim Speichern gilt Last-write-wins. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 15. System-Tray
|
## 16. System-Tray
|
||||||
|
|
||||||
Wird das Hauptfenster über das Schließen-Symbol (oder Alt+F4) geschlossen, ohne dass
|
Wird das Hauptfenster über das Schließen-Symbol (oder Alt+F4) geschlossen, ohne dass
|
||||||
ungespeicherte Änderungen oder ein aktiver Verarbeitungslauf vorliegen, **minimiert
|
ungespeicherte Änderungen oder ein aktiver Verarbeitungslauf vorliegen, **minimiert
|
||||||
@@ -752,7 +872,7 @@ Ein **Doppelklick** auf das Tray-Icon hat denselben Effekt wie „Öffnen".
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. Tab „Verlauf" (Historien-Tab)
|
## 17. Tab „Verlauf" (Historien-Tab)
|
||||||
|
|
||||||
Der dritte Tab **„Verlauf"** zeigt alle jemals verarbeiteten Dokumente mit Status,
|
Der dritte Tab **„Verlauf"** zeigt alle jemals verarbeiteten Dokumente mit Status,
|
||||||
Dateinamen und Verarbeitungsdetails. Die Daten stammen direkt aus der SQLite-Datenbank,
|
Dateinamen und Verarbeitungsdetails. Die Daten stammen direkt aus der SQLite-Datenbank,
|
||||||
@@ -771,7 +891,7 @@ Die Tabelle zeigt folgende Spalten:
|
|||||||
|
|
||||||
| Spalte | Inhalt |
|
| Spalte | Inhalt |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| Status-Icon | Symbol und Farbe gemäß Status-Mapping-Tabelle (Abschnitt 19) |
|
| Status-Icon | Symbol und Farbe gemäß Status-Mapping-Tabelle (Abschnitt 20) |
|
||||||
| Quelldateiname | Ursprünglicher Dateiname der PDF-Datei |
|
| Quelldateiname | Ursprünglicher Dateiname der PDF-Datei |
|
||||||
| Zieldateiname | Zuletzt vergebener Dateiname nach Umbenennung |
|
| Zieldateiname | Zuletzt vergebener Dateiname nach Umbenennung |
|
||||||
| Quellpfad | Letzter bekannter Quellordner |
|
| Quellpfad | Letzter bekannter Quellordner |
|
||||||
@@ -792,11 +912,38 @@ Die Tabelle zeigt folgende Spalten:
|
|||||||
Über dem Tab befinden sich drei Bedienelemente:
|
Über dem Tab befinden sich drei Bedienelemente:
|
||||||
|
|
||||||
- **Freitextsuche** – filtert über Quelldateiname und Zieldateiname, case-insensitiv
|
- **Freitextsuche** – filtert über Quelldateiname und Zieldateiname, case-insensitiv
|
||||||
- **Status-Filter** – ComboBox zur Auswahl eines bestimmten Status oder „Alle"
|
- **Status-Filter** – ComboBox zur Auswahl eines bestimmten Status oder „Alle Status"
|
||||||
- **„Aktualisieren"** – lädt die Liste neu aus der Datenbank (kein automatisches Echtzeit-Tailing)
|
- **„Suchen"** – startet die Suche sofort; alternativ die Enter-Taste im Suchfeld
|
||||||
|
|
||||||
Die Suche erfolgt datenbanksseitig; Sonderzeichen in der Sucheingabe werden korrekt behandelt.
|
Die Suche erfolgt datenbanksseitig; Sonderzeichen in der Sucheingabe werden korrekt behandelt.
|
||||||
|
|
||||||
|
#### Live-Suche
|
||||||
|
|
||||||
|
Die Freitextsuche reagiert **live** auf Tastatureingaben: 300 ms nach dem letzten
|
||||||
|
Tastendruck startet die Suche automatisch auf einem Hintergrund-Thread.
|
||||||
|
Der Such-Button und die Enter-Taste starten die Suche sofort ohne Verzögerung.
|
||||||
|
|
||||||
|
Nach jeder neuen Suchanfrage wird die Tabellenauswahl vollständig geleert;
|
||||||
|
Detailbereich und Aktionsbuttons werden zurückgesetzt. Ein leeres Suchfeld zeigt
|
||||||
|
alle Einträge (bis Limit 500).
|
||||||
|
|
||||||
|
### Mehrfachauswahl
|
||||||
|
|
||||||
|
Die Verlauf-Tabelle unterstützt **Mehrfachauswahl**:
|
||||||
|
|
||||||
|
| Geste | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| **Klick** | Einzelauswahl |
|
||||||
|
| **Strg+Klick** | Einzelnen Eintrag zur Auswahl hinzufügen oder entfernen |
|
||||||
|
| **Shift+Klick** | Bereich vom letzten zur aktuellen Zeile auswählen |
|
||||||
|
| **Strg+A** | Alle sichtbaren Einträge auswählen (**nur wenn die Tabelle den Fokus hat**) |
|
||||||
|
|
||||||
|
> **Hinweis:** Liegt der Fokus im Suchfeld, wirkt Strg+A als normale Textselektion
|
||||||
|
> im Suchfeld und selektiert keine Tabellenzeilen.
|
||||||
|
|
||||||
|
Bei Mehrfachauswahl zeigt der **Detailbereich** den Platzhaltertext
|
||||||
|
„X Einträge ausgewählt." (statt Dokumentdetails).
|
||||||
|
|
||||||
### Detailbereich
|
### Detailbereich
|
||||||
|
|
||||||
Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
|
Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
|
||||||
@@ -813,40 +960,104 @@ Ein Klick auf eine Zeile öffnet im rechten Bereich drei Informationsblöcke:
|
|||||||
|--------|--------|
|
|--------|--------|
|
||||||
| # | Versuchsnummer |
|
| # | Versuchsnummer |
|
||||||
| Datum | Endzeitpunkt des Versuchs |
|
| Datum | Endzeitpunkt des Versuchs |
|
||||||
| Status | Ergebnisstatus des Versuchs |
|
| Status | Ergebnisstatus des Versuchs (lesbarer Anzeigetext, kein Enum-Rohname) |
|
||||||
| Provider | Verwendeter KI-Provider |
|
| Provider | Verwendeter KI-Provider |
|
||||||
| Modell | Verwendetes Sprachmodell |
|
| Modell | Verwendetes Sprachmodell |
|
||||||
| Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname |
|
| Vorgeschlagener Name | Vom Versuch erzeugter Zieldateiname |
|
||||||
|
|
||||||
**KI-Begründung:** Das `ai_reasoning` des ausgewählten Versuchs als nicht editierbarer Text.
|
**KI-Begründung / Fehlerursache:**
|
||||||
|
|
||||||
|
Das `ai_reasoning` des zuletzt ausgewählten Versuchs als nicht editierbarer Text.
|
||||||
|
Ist kein Reasoning gespeichert, erscheint ein gedimmter Platzhaltertext
|
||||||
|
„Keine KI-Begründung für diesen Versuch gespeichert."
|
||||||
|
|
||||||
|
Bei Einträgen mit Status `FAILED_FINAL`, `FAILED_RETRYABLE` oder
|
||||||
|
`SKIPPED_FINAL_FAILURE` wird zusätzlich die **Fehlerursache** des letzten
|
||||||
|
fehlgeschlagenen Versuchs angezeigt. Liegt keine Fehlerursache vor (z. B. ältere
|
||||||
|
Einträge), erscheint ebenfalls ein Platzhaltertext.
|
||||||
|
|
||||||
### Aktionen
|
### Aktionen
|
||||||
|
|
||||||
Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung:
|
Unterhalb der Dokumentenliste stehen zwei Aktionen zur Verfügung.
|
||||||
|
**Beide Aktionen unterstützen Mehrfachauswahl** (≥ 1 Eintrag):
|
||||||
|
|
||||||
**„Status zurücksetzen"**
|
**„Status zurücksetzen"**
|
||||||
|
|
||||||
Setzt den Status des ausgewählten Dokuments auf „Wartet auf Verarbeitung" zurück,
|
Setzt den Status der ausgewählten Dokumente auf „Wartet auf Verarbeitung" zurück,
|
||||||
sodass es beim nächsten Verarbeitungslauf automatisch erneut verarbeitet wird.
|
sodass sie beim nächsten Verarbeitungslauf automatisch erneut verarbeitet werden.
|
||||||
Die Versuchshistorie bleibt vollständig erhalten – kein Versuch wird gelöscht.
|
Die Versuchshistorie bleibt vollständig erhalten – kein Versuch wird gelöscht.
|
||||||
Vor der Aktion erscheint ein Bestätigungsdialog.
|
Vor der Aktion erscheint ein Bestätigungsdialog: „X Einträge zurücksetzen?"
|
||||||
|
|
||||||
|
Bei Mehrfachauswahl werden Einträge einzeln zurückgesetzt. Nach Abschluss erscheint
|
||||||
|
eine kompakte Zusammenfassung „X von Y erfolgreich verarbeitet." Detaillierte
|
||||||
|
Einzelfehler werden geloggt.
|
||||||
|
|
||||||
Wann sinnvoll: wenn die Ursache eines Fehlers behoben wurde (z. B. OCR nachträglich
|
Wann sinnvoll: wenn die Ursache eines Fehlers behoben wurde (z. B. OCR nachträglich
|
||||||
durchgeführt, Passwortschutz entfernt) und das Dokument erneut verarbeitet werden soll.
|
durchgeführt, Passwortschutz entfernt) und das Dokument erneut verarbeitet werden soll.
|
||||||
|
|
||||||
**„Eintrag löschen"**
|
**„Eintrag löschen"**
|
||||||
|
|
||||||
Löscht den Stammsatz und alle Verarbeitungsversuche des ausgewählten Dokuments
|
Löscht die Stammsätze und alle Verarbeitungsversuche der ausgewählten Dokumente
|
||||||
vollständig aus der Datenbank. Diese Aktion ist **nicht rückgängig zu machen**.
|
vollständig aus der Datenbank. Diese Aktion ist **nicht rückgängig zu machen**.
|
||||||
Vor der Aktion erscheint ein Bestätigungsdialog mit einem ausdrücklichen Hinweis
|
Vor der Aktion erscheint ein Bestätigungsdialog: „X Einträge unwiderruflich löschen?"
|
||||||
auf die Unwiderruflichkeit.
|
|
||||||
|
Bei Mehrfachauswahl gilt dieselbe Partial-Success-Logik wie beim Zurücksetzen.
|
||||||
|
|
||||||
**Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert.
|
**Hinweis:** Beide Aktionen sind während eines laufenden Verarbeitungslaufs deaktiviert.
|
||||||
Ein Hinweis „Aktion während Verarbeitungslauf nicht möglich." wird angezeigt.
|
Nach Laufende werden die Buttons automatisch reaktiviert, sofern eine Auswahl besteht –
|
||||||
|
ohne dass der Benutzer die Auswahl erneuern muss.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. Tab „Prompt" (Prompt-Editor)
|
## 17a. Neue Datenbank anlegen
|
||||||
|
|
||||||
|
Über **Datenbank → Neue Datenbank anlegen...** in der Menüleiste kann eine neue,
|
||||||
|
leere SQLite-Datenbank erstellt und sofort als aktive Datenbank der Anwendung
|
||||||
|
gesetzt werden – ohne Neustart.
|
||||||
|
|
||||||
|
### Voraussetzung
|
||||||
|
|
||||||
|
Der Menüpunkt ist nur aktiv, wenn kein Verarbeitungslauf läuft.
|
||||||
|
|
||||||
|
### Ablauf
|
||||||
|
|
||||||
|
1. Ein Dateidialog öffnet sich (Filter: `*.sqlite` und `*.db`). Neue Zieldatei
|
||||||
|
wählen oder eingeben.
|
||||||
|
2. Die Anwendung prüft, ob die gewählte Datei identisch mit der aktuell aktiven
|
||||||
|
Datenbank ist (normalisierter, case-insensitiver Pfadvergleich). Bei
|
||||||
|
Übereinstimmung erscheint eine Fehlermeldung, kein Überschreiben.
|
||||||
|
3. Existiert die gewählte Datei bereits (andere als aktive DB): Bestätigungsdialog
|
||||||
|
„Die Datei existiert bereits. Überschreiben?"
|
||||||
|
4. Die neue DB wird als temporäre Datei im Zielverzeichnis erzeugt. Flyway
|
||||||
|
führt alle Migrationsskripte auf den neuesten Schema-Stand aus.
|
||||||
|
5. Verbindungstest: Verbindung öffnen, Flyway-History prüfen, Leseabfrage prüfen.
|
||||||
|
6. Nach erfolgreichem Test: atomarer Move zur Zieldatei
|
||||||
|
(`ATOMIC_MOVE + REPLACE_EXISTING`). Schlägt dies fehl, bricht der Vorgang
|
||||||
|
mit einer klaren Fehlermeldung ab.
|
||||||
|
7. Die aktive Datenbankverbindung wechselt zur neuen DB.
|
||||||
|
8. Der Verlauf-Tab lädt neu: „Noch keine Verarbeitungen vorhanden."
|
||||||
|
9. Die Statuszeile aktualisiert den DB-Pfad.
|
||||||
|
10. Die Konfiguration wird als geändert markiert (Dirty-State im Konfig-Tab).
|
||||||
|
11. Im Meldungsbereich erscheint der Hinweis:
|
||||||
|
„Neue Datenbank ist aktiv. Konfiguration speichern, damit diese Datenbank
|
||||||
|
beim nächsten Start verwendet wird."
|
||||||
|
|
||||||
|
### Fehlerfall
|
||||||
|
|
||||||
|
Schlägt ein Schritt fehl, bleibt die bisherige Datenbank vollständig unverändert
|
||||||
|
in Betrieb. Die temporäre Datei wird gelöscht. Ein Fehlerdialog erscheint mit
|
||||||
|
einer konkreten Meldung.
|
||||||
|
|
||||||
|
### Wichtiger Hinweis
|
||||||
|
|
||||||
|
**Die Konfigurationsdatei wird durch den DB-Wechsel nicht automatisch gespeichert.**
|
||||||
|
Damit die neue Datenbank beim nächsten Start der Anwendung verwendet wird, muss
|
||||||
|
die Konfiguration explizit über „Speichern" oder „Speichern unter" gesichert werden.
|
||||||
|
Der Dirty-State im Konfig-Tab und der Hinweis im Meldungsbereich erinnern daran.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Tab „Prompt" (Prompt-Editor)
|
||||||
|
|
||||||
Der vierte Tab **„Prompt"** ermöglicht das Lesen, Bearbeiten und Speichern der
|
Der vierte Tab **„Prompt"** ermöglicht das Lesen, Bearbeiten und Speichern der
|
||||||
KI-Prompt-Datei direkt in der GUI – ohne externen Editor.
|
KI-Prompt-Datei direkt in der GUI – ohne externen Editor.
|
||||||
@@ -891,7 +1102,7 @@ Ein Klick legt eine Prompt-Datei mit dem deutschen Standard-Template an
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 18. Statuszeile
|
## 19. Statuszeile
|
||||||
|
|
||||||
Am unteren Rand des Hauptfensters ist permanent eine **Statuszeile** (`GuiStatusBar`)
|
Am unteren Rand des Hauptfensters ist permanent eine **Statuszeile** (`GuiStatusBar`)
|
||||||
sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
|
sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
|
||||||
@@ -912,7 +1123,7 @@ sichtbar. Sie ist auf allen Tabs sichtbar und zeigt drei Segmente:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 19. Fehlerstatus – Bedeutung und Unterscheidung
|
## 20. Fehlerstatus – Bedeutung und Unterscheidung
|
||||||
|
|
||||||
Zwei Fehlerstatus werden in der GUI klar unterschieden. Die Unterscheidung ist wichtig,
|
Zwei Fehlerstatus werden in der GUI klar unterschieden. Die Unterscheidung ist wichtig,
|
||||||
um zu entscheiden, ob eine erneute Verarbeitung sinnvoll ist.
|
um zu entscheiden, ob eine erneute Verarbeitung sinnvoll ist.
|
||||||
@@ -942,7 +1153,7 @@ werden nicht mehr unternommen.
|
|||||||
**Was passiert:** Das Dokument wird in späteren Läufen übersprungen.
|
**Was passiert:** Das Dokument wird in späteren Läufen übersprungen.
|
||||||
|
|
||||||
**Mögliche Abhilfe:** Wenn die Ursache behoben wurde (z. B. OCR wurde nachträglich
|
**Mögliche Abhilfe:** Wenn die Ursache behoben wurde (z. B. OCR wurde nachträglich
|
||||||
durchgeführt), kann der Status im **Verlauf-Tab** (Abschnitt 16) manuell zurückgesetzt
|
durchgeführt), kann der Status im **Verlauf-Tab** (Abschnitt 17) manuell zurückgesetzt
|
||||||
werden. Das Dokument wird dann beim nächsten Lauf erneut verarbeitet. Alternativ kann
|
werden. Das Dokument wird dann beim nächsten Lauf erneut verarbeitet. Alternativ kann
|
||||||
der Eintrag vollständig gelöscht werden, damit die Datei als neu erkannt wird.
|
der Eintrag vollständig gelöscht werden, damit die Datei als neu erkannt wird.
|
||||||
|
|
||||||
@@ -966,7 +1177,7 @@ beschreiben den Status auch ohne Farbwahrnehmung eindeutig.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 20. Tooltips
|
## 21. Tooltips
|
||||||
|
|
||||||
Auf den meisten interaktiven Elementen der GUI sind Tooltips gesetzt, die beim Hover über
|
Auf den meisten interaktiven Elementen der GUI sind Tooltips gesetzt, die beim Hover über
|
||||||
ein Element erscheinen. Sie erklären kurz den Zweck des Elements.
|
ein Element erscheinen. Sie erklären kurz den Zweck des Elements.
|
||||||
@@ -978,7 +1189,7 @@ Tooltips sind unter anderem vorhanden auf:
|
|||||||
- **Toolbar-Buttons** – Neu, Öffnen, Speichern, Speichern unter, Validieren,
|
- **Toolbar-Buttons** – Neu, Öffnen, Speichern, Speichern unter, Validieren,
|
||||||
Technische Tests ausführen
|
Technische Tests ausführen
|
||||||
- **Status-Icons** im Verarbeitungslauf-Tab – Text gemäß Status-Mapping-Tabelle
|
- **Status-Icons** im Verarbeitungslauf-Tab – Text gemäß Status-Mapping-Tabelle
|
||||||
(Abschnitt 19)
|
(Abschnitt 20)
|
||||||
- **Buttons „Dateiname übernehmen"** und **„Zurücksetzen auf KI-Vorschlag"** im
|
- **Buttons „Dateiname übernehmen"** und **„Zurücksetzen auf KI-Vorschlag"** im
|
||||||
Dateiname-Editor (Abschnitt 13b)
|
Dateiname-Editor (Abschnitt 13b)
|
||||||
|
|
||||||
|
|||||||
@@ -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
+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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+509
-39
@@ -40,6 +40,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState;
|
|||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiVisibleProviderSection;
|
||||||
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
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.editor.ApiKeyResolutionPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
import de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator;
|
||||||
@@ -57,6 +58,7 @@ import javafx.scene.Node;
|
|||||||
import javafx.scene.Parent;
|
import javafx.scene.Parent;
|
||||||
import javafx.scene.control.Alert;
|
import javafx.scene.control.Alert;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
|
import javafx.scene.control.ButtonBar;
|
||||||
import javafx.scene.control.ButtonType;
|
import javafx.scene.control.ButtonType;
|
||||||
import javafx.scene.control.CheckBox;
|
import javafx.scene.control.CheckBox;
|
||||||
import javafx.scene.control.ComboBox;
|
import javafx.scene.control.ComboBox;
|
||||||
@@ -122,6 +124,12 @@ import javafx.stage.Window;
|
|||||||
* Thread via {@code Platform.runLater}.
|
* Thread via {@code Platform.runLater}.
|
||||||
*/
|
*/
|
||||||
public final class GuiConfigurationEditorWorkspace {
|
public final class GuiConfigurationEditorWorkspace {
|
||||||
|
private static final String NO_PROMPT_PATH_MSG = "Kein Prompt-Pfad konfiguriert.";
|
||||||
|
private static final String OPERATION_VALIDATE = "Validierung";
|
||||||
|
private static final String PROPERTIES_FILTER_EXT = "*.properties";
|
||||||
|
private static final String PROPERTIES_FILTER_DESC = "Properties-Dateien";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static final Logger LOG = LogManager.getLogger(GuiConfigurationEditorWorkspace.class);
|
private static final Logger LOG = LogManager.getLogger(GuiConfigurationEditorWorkspace.class);
|
||||||
private static final String WELCOME_TEXT =
|
private static final String WELCOME_TEXT =
|
||||||
@@ -415,11 +423,46 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridge-Port zum Prompt-Editor-Use-Case. Wird vom {@link GuiPromptEditorTab} genutzt,
|
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort}
|
||||||
* um den Prompt-Inhalt zu laden, zu speichern und eine Standard-Prompt-Datei anzulegen.
|
* erzeugt. Wird verwendet, wenn eine neue Konfiguration geladen oder gespeichert wird,
|
||||||
|
* um den {@link GuiPromptEditorTab} mit einem aktualisierten Port zu versorgen.
|
||||||
* Supplied by Bootstrap via the startup context.
|
* Supplied by Bootstrap via the startup context.
|
||||||
*/
|
*/
|
||||||
private final GuiPromptEditorPort promptEditorPort;
|
private final GuiPromptEditorPortFactory promptEditorPortFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge zur DB-Anlage- und Wechsellogik. Wird vom Menüpunkt
|
||||||
|
* „Datenbank → Neue Datenbank anlegen…" ausgelöst.
|
||||||
|
*/
|
||||||
|
private final GuiCreateNewDatabasePort createNewDatabasePort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback, der nach jedem erfolgreichen Datei-Öffnen auf dem Hintergrund-Thread
|
||||||
|
* aufgerufen wird, um den Bootstrap-seitigen Anwendungskontext und den Scheduler
|
||||||
|
* zu initialisieren.
|
||||||
|
*/
|
||||||
|
private final GuiApplicationContextInitializer applicationContextInitializer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktiver DB-Busy-Zustand während einer laufenden Datenbank-Anlage. Solange
|
||||||
|
* dieser Zustand aktiv ist, sind alle DB-lesenden und DB-schreibenden Aktionen
|
||||||
|
* der GUI gesperrt (vgl. {@link #applyDbBusyLock()}).
|
||||||
|
* <p>
|
||||||
|
* Als JavaFX-Property realisiert, damit die Menüleiste den Zustand direkt
|
||||||
|
* über {@code disableProperty().bind(...)} auswerten kann.
|
||||||
|
*/
|
||||||
|
private final javafx.beans.property.SimpleBooleanProperty dbBusyForDatabaseCreation =
|
||||||
|
new javafx.beans.property.SimpleBooleanProperty(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hintergrund-Worker-Thread für die DB-Anlage; einzel-threadig, damit nicht
|
||||||
|
* mehrere DB-Anlagen gleichzeitig laufen können.
|
||||||
|
*/
|
||||||
|
private final ExecutorService createNewDatabaseExecutor = Executors.newSingleThreadExecutor(runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "gui-create-new-database");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Second main tab of the window that drives the live processing-run view. Created
|
* Second main tab of the window that drives the live processing-run view. Created
|
||||||
@@ -429,25 +472,37 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
private final GuiBatchRunTab batchRunTab;
|
private final GuiBatchRunTab batchRunTab;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dritter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion
|
* Dritter Haupt-Tab: Scheduler-Steuerung. Wird während der Workspace-Konstruktion
|
||||||
|
* erstellt und in den {@link #tabPane} eingehängt.
|
||||||
|
*/
|
||||||
|
private final GuiSchedulerTab schedulerTab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vierter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion
|
||||||
* erstellt und in den {@link #tabPane} eingehängt.
|
* erstellt und in den {@link #tabPane} eingehängt.
|
||||||
*/
|
*/
|
||||||
private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab;
|
private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vierter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
|
* Fünfter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt
|
||||||
* und in den {@link #tabPane} eingehängt.
|
* und in den {@link #tabPane} eingehängt.
|
||||||
*/
|
*/
|
||||||
private final GuiPromptEditorTab promptEditorTab;
|
private final GuiPromptEditorTab promptEditorTab;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hint banner shown at the top of the configuration tab while a processing run is
|
* Hint banner shown at the top of the configuration tab while a processing run or
|
||||||
* active. Visible + managed state are flipped from the batch run tab's listener when
|
* the automatic scheduler is active. Visible + managed state are controlled by
|
||||||
* the running flag toggles.
|
* {@link #applyConfigTabLockState()}.
|
||||||
*/
|
*/
|
||||||
final Label configurationLockBanner = new Label(
|
final Label configurationLockBanner = new Label(
|
||||||
"Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar");
|
"Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code true} while the automatic scheduler is in any non-{@code STOPPED} state.
|
||||||
|
* Updated by {@link #updateLockState(SchedulerStatus)} from the 1 Hz refresh timeline.
|
||||||
|
*/
|
||||||
|
private boolean schedulerLockActive = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reference to the configuration tab so the running-state listener can disable its
|
* Reference to the configuration tab so the running-state listener can disable its
|
||||||
* content while a batch run is active.
|
* content while a batch run is active.
|
||||||
@@ -494,6 +549,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
() -> editorState.loadedFileSnapshot()
|
() -> editorState.loadedFileSnapshot()
|
||||||
.map(snapshot -> snapshot.filePath().toString())
|
.map(snapshot -> snapshot.filePath().toString())
|
||||||
.orElse(""),
|
.orElse(""),
|
||||||
|
() -> editorState.values().logDirectory(),
|
||||||
pendingMessages,
|
pendingMessages,
|
||||||
report -> {
|
report -> {
|
||||||
technicalTestsButton.setDisable(false);
|
technicalTestsButton.setDisable(false);
|
||||||
@@ -510,7 +566,9 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
|
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
|
||||||
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
|
||||||
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
|
||||||
this.promptEditorPort = effectiveContext.promptEditorPort();
|
this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory();
|
||||||
|
this.createNewDatabasePort = effectiveContext.createNewDatabasePort();
|
||||||
|
this.applicationContextInitializer = effectiveContext.applicationContextInitializer();
|
||||||
this.batchRunTab = new GuiBatchRunTab(
|
this.batchRunTab = new GuiBatchRunTab(
|
||||||
() -> this.batchRunLauncher,
|
() -> this.batchRunLauncher,
|
||||||
() -> this.miniRunLauncher,
|
() -> this.miniRunLauncher,
|
||||||
@@ -522,7 +580,12 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
() -> this.manualFileCopyPort,
|
() -> this.manualFileCopyPort,
|
||||||
() -> this.historicalDocumentContextPort,
|
() -> this.historicalDocumentContextPort,
|
||||||
this::editorSourceFolder,
|
this::editorSourceFolder,
|
||||||
this::editorTargetFolder);
|
this::editorTargetFolder,
|
||||||
|
effectiveContext.configurationFileLockPort());
|
||||||
|
|
||||||
|
this.schedulerTab = new GuiSchedulerTab(
|
||||||
|
effectiveContext.schedulerControlUseCase(),
|
||||||
|
() -> editorState.isDirty());
|
||||||
|
|
||||||
this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab(
|
this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab(
|
||||||
effectiveContext.historyOverviewPort(),
|
effectiveContext.historyOverviewPort(),
|
||||||
@@ -532,6 +595,13 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
this.batchRunTab::isRunning,
|
this.batchRunTab::isRunning,
|
||||||
this::loadedConfigurationPath);
|
this::loadedConfigurationPath);
|
||||||
|
|
||||||
|
// Aktionsbuttons im Verlauf-Tab reaktivieren, sobald der Lauf beendet ist
|
||||||
|
this.batchRunTab.runningProperty().addListener((obs, wasRunning, running) -> {
|
||||||
|
if (!running) {
|
||||||
|
Platform.runLater(this.historyTab::notifyRunEnded);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
|
String configuredPromptPath = effectiveContext.initialState().values().promptTemplateFile();
|
||||||
int maxTitleLength;
|
int maxTitleLength;
|
||||||
try {
|
try {
|
||||||
@@ -541,7 +611,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
maxTitleLength = 60;
|
maxTitleLength = 60;
|
||||||
}
|
}
|
||||||
this.promptEditorTab = new GuiPromptEditorTab(
|
this.promptEditorTab = new GuiPromptEditorTab(
|
||||||
this.promptEditorPort, configuredPromptPath, maxTitleLength);
|
effectiveContext.promptEditorPort(), configuredPromptPath, maxTitleLength);
|
||||||
|
|
||||||
configureRoot();
|
configureRoot();
|
||||||
configureHeader(effectiveContext.startupNotice());
|
configureHeader(effectiveContext.startupNotice());
|
||||||
@@ -645,23 +715,41 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the "batch run active" UI lock state to the configuration tab and the
|
* Applies the "batch run active" UI lock state to the configuration tab.
|
||||||
* action bar.
|
|
||||||
* <p>
|
* <p>
|
||||||
* While a run is active the configuration editor is made non-interactive, the lock
|
* Delegates to {@link #applyConfigTabLockState()} so that both the batch-run lock
|
||||||
* banner is shown at the top of Tab 1, and the main action buttons (Neu, Öffnen,
|
* and the scheduler lock are evaluated together. Called whenever the batch-run
|
||||||
* Speichern, Speichern unter) are disabled. When the run ends, the locks are
|
* running state changes.
|
||||||
* released and the editor returns to its normal state.
|
|
||||||
*/
|
*/
|
||||||
void applyBatchRunLockState() {
|
void applyBatchRunLockState() {
|
||||||
boolean running = batchRunTab != null && batchRunTab.isRunning();
|
applyConfigTabLockState();
|
||||||
configurationLockBanner.setVisible(running);
|
}
|
||||||
configurationLockBanner.setManaged(running);
|
|
||||||
sectionsBox.setDisable(running);
|
/**
|
||||||
newButton.setDisable(running);
|
* Evaluates the combined lock state (batch run active <em>or</em> scheduler active)
|
||||||
openButton.setDisable(running);
|
* and applies it to the configuration tab.
|
||||||
saveButton.setDisable(running);
|
* <p>
|
||||||
saveAsButton.setDisable(running);
|
* When either source is locked the banner is shown, all input sections are disabled
|
||||||
|
* and the action buttons (Neu, Öffnen, Speichern, Speichern unter) are disabled.
|
||||||
|
* The banner text describes the dominant lock source.
|
||||||
|
*/
|
||||||
|
private void applyConfigTabLockState() {
|
||||||
|
boolean batchRunning = batchRunTab != null && batchRunTab.isRunning();
|
||||||
|
boolean locked = batchRunning || schedulerLockActive;
|
||||||
|
|
||||||
|
String bannerText = schedulerLockActive
|
||||||
|
? "⚠ Konfiguration gesperrt – Scheduler läuft (oder Lauf aktiv)."
|
||||||
|
+ " Scheduler beenden bzw. Lauf abwarten um Änderungen vorzunehmen."
|
||||||
|
: "Konfiguration während eines laufenden Verarbeitungslaufs nicht editierbar";
|
||||||
|
|
||||||
|
configurationLockBanner.setText(bannerText);
|
||||||
|
configurationLockBanner.setVisible(locked);
|
||||||
|
configurationLockBanner.setManaged(locked);
|
||||||
|
sectionsBox.setDisable(locked);
|
||||||
|
newButton.setDisable(locked);
|
||||||
|
openButton.setDisable(locked);
|
||||||
|
saveButton.setDisable(locked);
|
||||||
|
saveAsButton.setDisable(locked);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -911,7 +999,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
Window owner = root.getScene() == null ? null : root.getScene().getWindow();
|
Window owner = root.getScene() == null ? null : root.getScene().getWindow();
|
||||||
FileChooser fileChooser = new FileChooser();
|
FileChooser fileChooser = new FileChooser();
|
||||||
fileChooser.setTitle("Konfiguration öffnen");
|
fileChooser.setTitle("Konfiguration öffnen");
|
||||||
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Properties-Dateien", "*.properties"));
|
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(PROPERTIES_FILTER_DESC, PROPERTIES_FILTER_EXT));
|
||||||
if (owner != null && editorState.hasLoadedFileSnapshot()) {
|
if (owner != null && editorState.hasLoadedFileSnapshot()) {
|
||||||
Path currentPath = editorState.loadedFileSnapshot().orElseThrow().filePath();
|
Path currentPath = editorState.loadedFileSnapshot().orElseThrow().filePath();
|
||||||
Path parent = currentPath.getParent();
|
Path parent = currentPath.getParent();
|
||||||
@@ -944,7 +1032,13 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
GuiConfigurationEditorState loadedState = configurationFileLoader.load(configFilePath);
|
GuiConfigurationEditorState loadedState = configurationFileLoader.load(configFilePath);
|
||||||
// Speichern des Pfads als letzte geladene Konfiguration
|
// Speichern des Pfads als letzte geladene Konfiguration
|
||||||
saveLastConfigurationPath(configFilePath);
|
saveLastConfigurationPath(configFilePath);
|
||||||
Platform.runLater(() -> applyEditorState(loadedState));
|
// Anwendungskontext und Scheduler initialisieren; Ergebnis auf dem FX-Thread auswerten.
|
||||||
|
GuiApplicationContextInitializer.InitResult initResult =
|
||||||
|
applicationContextInitializer.initialize(configFilePath);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
applyEditorState(loadedState);
|
||||||
|
initResult.schedulerControlUseCase().ifPresent(schedulerTab::onSchedulerAvailable);
|
||||||
|
});
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
Platform.runLater(() -> showError("Konfiguration konnte nicht geladen werden: "
|
Platform.runLater(() -> showError("Konfiguration konnte nicht geladen werden: "
|
||||||
+ safeMessage(exception)));
|
+ safeMessage(exception)));
|
||||||
@@ -981,7 +1075,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
FileChooser fileChooser = saveFileChooserFactory.get();
|
FileChooser fileChooser = saveFileChooserFactory.get();
|
||||||
fileChooser.setTitle("Konfiguration speichern");
|
fileChooser.setTitle("Konfiguration speichern");
|
||||||
fileChooser.getExtensionFilters().add(
|
fileChooser.getExtensionFilters().add(
|
||||||
new FileChooser.ExtensionFilter("Properties-Dateien", "*.properties"));
|
new FileChooser.ExtensionFilter(PROPERTIES_FILTER_DESC, PROPERTIES_FILTER_EXT));
|
||||||
|
|
||||||
// Propose the default path relative to the working directory.
|
// Propose the default path relative to the working directory.
|
||||||
Path proposedDir = DEFAULT_SAVE_PATH.getParent();
|
Path proposedDir = DEFAULT_SAVE_PATH.getParent();
|
||||||
@@ -1009,6 +1103,291 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
checkExistsAndSave(targetPath, () -> { });
|
checkExistsAndSave(targetPath, () -> { });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true}, wenn aktuell gerade eine Datenbank-Anlage läuft und der
|
||||||
|
* Menüpunkt „Datenbank → Neue Datenbank anlegen…" daher gesperrt ist.
|
||||||
|
*
|
||||||
|
* @return aktueller DB-Busy-Zustand
|
||||||
|
*/
|
||||||
|
public boolean isDbBusyForDatabaseCreation() {
|
||||||
|
return dbBusyForDatabaseCreation.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty} für den
|
||||||
|
* DB-Busy-Zustand. Wird von der Menüleiste genutzt, um den Menüpunkt
|
||||||
|
* „Neue Datenbank anlegen…" während einer laufenden Anlage automatisch zu
|
||||||
|
* deaktivieren.
|
||||||
|
*
|
||||||
|
* @return read-only Property; nie {@code null}
|
||||||
|
*/
|
||||||
|
public javafx.beans.property.BooleanProperty dbBusyForDatabaseCreationProperty() {
|
||||||
|
return dbBusyForDatabaseCreation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die {@link javafx.beans.property.ReadOnlyBooleanProperty}, die den
|
||||||
|
* Lauf-aktiv-Zustand des Verarbeitungslauf-Tabs spiegelt. Wird von der
|
||||||
|
* Menüleiste genutzt, um den Menüpunkt „Neue Datenbank anlegen…" während
|
||||||
|
* eines laufenden Verarbeitungslaufs zu deaktivieren.
|
||||||
|
*
|
||||||
|
* @return read-only Property; nie {@code null}
|
||||||
|
*/
|
||||||
|
public javafx.beans.property.ReadOnlyBooleanProperty batchRunRunningProperty() {
|
||||||
|
return batchRunTab.runningProperty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet einen aktuellen Scheduler-Status-Snapshot an alle betroffenen Tabs weiter.
|
||||||
|
* <p>
|
||||||
|
* Wird von der zentralen Status-Refresh-Timeline (1 Hz) auf dem JavaFX Application
|
||||||
|
* Thread aufgerufen. Die Methode delegiert an:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.GuiBatchRunTab#updateSchedulerState}
|
||||||
|
* – Schaltflächen-Zustand im Verarbeitungslauf-Tab</li>
|
||||||
|
* <li>{@link GuiSchedulerTab#updateStatus} – Statusanzeige im Scheduler-Tab</li>
|
||||||
|
* <li>{@link #updateLockState} – Banner und Speichern-Button im Konfig-Tab</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public void onSchedulerStatusRefresh(SchedulerStatus status) {
|
||||||
|
batchRunTab.updateSchedulerState(status);
|
||||||
|
schedulerTab.updateStatus(status);
|
||||||
|
updateLockState(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den aktuellen Scheduler-Status vom verdrahteten Use Case und reicht ihn an
|
||||||
|
* alle betroffenen Tabs weiter.
|
||||||
|
* <p>
|
||||||
|
* Im Gegensatz zu einem direkten Lesen am unveränderlichen
|
||||||
|
* {@code GuiStartupContext} berücksichtigt diese Methode den nach einem
|
||||||
|
* erfolgreichen Datei-Öffnen erst zur Laufzeit verdrahteten Use Case
|
||||||
|
* (Auto-Load der zuletzt geladenen Konfiguration oder manuelles Öffnen).
|
||||||
|
* Ist kein Use Case verdrahtet, ist der Aufruf ein No-op.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void refreshSchedulerStatus() {
|
||||||
|
schedulerTab.currentSchedulerUseCase()
|
||||||
|
.ifPresent(uc -> onSchedulerStatusRefresh(uc.getStatus()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der aktuell verdrahtete Scheduler-Use-Case in einem aktiven
|
||||||
|
* Zustand (Zustand != {@code STOPPED}) ist.
|
||||||
|
* <p>
|
||||||
|
* Liest den Use Case dynamisch aus dem {@link GuiSchedulerTab}, damit auch
|
||||||
|
* der nach erfolgreichem Datei-Öffnen erst zur Laufzeit verdrahtete Use Case
|
||||||
|
* erfasst wird. Ist kein Use Case verdrahtet, wird {@code false} zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn ein Use Case verdrahtet ist und sein Zustand
|
||||||
|
* als aktiv gilt; sonst {@code false}
|
||||||
|
*/
|
||||||
|
public boolean isSchedulerActive() {
|
||||||
|
return schedulerTab.currentSchedulerUseCase()
|
||||||
|
.map(uc -> uc.getStatus().state().isActive())
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert den Sperr-Zustand des Konfig-Tabs anhand des aktuellen Scheduler-Status.
|
||||||
|
* <p>
|
||||||
|
* Setzt {@link #schedulerLockActive} und ruft {@link #applyConfigTabLockState()} auf,
|
||||||
|
* sodass Banner, Eingabefelder und Aktionsbuttons des Konfig-Tabs sofort in den
|
||||||
|
* korrekten Zustand versetzt werden.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public void updateLockState(SchedulerStatus status) {
|
||||||
|
schedulerLockActive = status.state().isActive();
|
||||||
|
applyConfigTabLockState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behandelt die Aktion „Datenbank → Neue Datenbank anlegen…".
|
||||||
|
* <p>
|
||||||
|
* Öffnet einen FileChooser (Filter {@code *.db}, {@code *.sqlite}), prüft den Zielpfad
|
||||||
|
* gegen die aktive Datenbank, holt ggf. eine Überschreib-Bestätigung ein und
|
||||||
|
* delegiert die eigentliche Anlage an
|
||||||
|
* {@link GuiCreateNewDatabasePort#createNewDatabase(Path)} auf einem
|
||||||
|
* Hintergrund-Worker-Thread.
|
||||||
|
* <p>
|
||||||
|
* Während der Ausführung ist die GUI in einem DB-Busy-Zustand: alle
|
||||||
|
* DB-lesenden und DB-schreibenden Aktionen sind deaktiviert. Der Zustand
|
||||||
|
* wird nach Erfolg oder Fehler zuverlässig zurückgesetzt.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void requestCreateNewDatabase() {
|
||||||
|
if (dbBusyForDatabaseCreation.get()) {
|
||||||
|
LOG.debug("GUI-Editor: Anlage einer neuen Datenbank ist bereits in Arbeit – Klick ignoriert.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (batchRunTab != null && batchRunTab.isRunning()) {
|
||||||
|
showError("Während eines Verarbeitungslaufs kann keine neue Datenbank angelegt werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Window owner = root.getScene() == null ? null : root.getScene().getWindow();
|
||||||
|
FileChooser fileChooser = saveFileChooserFactory.get();
|
||||||
|
fileChooser.setTitle("Neue Datenbank anlegen");
|
||||||
|
fileChooser.getExtensionFilters().add(
|
||||||
|
new FileChooser.ExtensionFilter("SQLite-Datenbank (*.db, *.sqlite)", "*.db", "*.sqlite"));
|
||||||
|
|
||||||
|
// Vorschlagsverzeichnis: SQLite-Pfad aus der aktuellen Konfiguration, sofern gesetzt
|
||||||
|
String currentSqlite = editorState.values().sqliteFile();
|
||||||
|
String activeExtension = resolveDbExtension(currentSqlite);
|
||||||
|
fileChooser.setInitialFileName("neue-datenbank" + activeExtension);
|
||||||
|
if (currentSqlite != null && !currentSqlite.isBlank()) {
|
||||||
|
try {
|
||||||
|
Path proposedDir = Path.of(currentSqlite).toAbsolutePath().getParent();
|
||||||
|
if (proposedDir != null && proposedDir.toFile().isDirectory()) {
|
||||||
|
fileChooser.setInitialDirectory(proposedDir.toFile());
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// bei ungültigem Pfad: kein Vorschlagsverzeichnis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File selectedFile;
|
||||||
|
try {
|
||||||
|
selectedFile = saveDialogFunction.apply(fileChooser, owner);
|
||||||
|
} catch (UnsupportedOperationException e) {
|
||||||
|
LOG.debug("GUI-Editor: Datenbank-Speichern-Dialog nicht verfügbar (headless).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedFile == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path requestedTarget = selectedFile.toPath().toAbsolutePath().normalize();
|
||||||
|
|
||||||
|
// Bestätigungsdialog wenn Datei bereits existiert (egal ob fremd oder aktive DB —
|
||||||
|
// die endgültige Sicherheitsprüfung gegen die aktive DB übernimmt der Use-Case).
|
||||||
|
if (java.nio.file.Files.exists(requestedTarget)) {
|
||||||
|
ButtonType ueberschreiben = new ButtonType("Überschreiben", ButtonBar.ButtonData.OK_DONE);
|
||||||
|
ButtonType abbrechen = new ButtonType("Abbrechen", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||||
|
Optional<ButtonType> choice = showConfirmation(
|
||||||
|
"Datei überschreiben?",
|
||||||
|
"Die Datei existiert bereits:\n" + requestedTarget
|
||||||
|
+ "\n\nDie vorhandene Datei wird durch eine neue, leere SQLite-Datenbank ersetzt.\nFortfahren?",
|
||||||
|
abbrechen,
|
||||||
|
ueberschreiben);
|
||||||
|
if (choice.isEmpty() || !choice.get().equals(ueberschreiben)) {
|
||||||
|
LOG.info("GUI-Editor: Anlage einer neuen Datenbank vom Benutzer abgebrochen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startCreateNewDatabaseWorker(requestedTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktiviert die DB-Busy-Sperre auf der Oberfläche und reicht den eigentlichen
|
||||||
|
* Aufruf des {@link GuiCreateNewDatabasePort} an einen Daemon-Worker-Thread weiter.
|
||||||
|
*
|
||||||
|
* @param targetFile der bereits geprüfte Zielpfad; nie {@code null}
|
||||||
|
*/
|
||||||
|
private void startCreateNewDatabaseWorker(Path targetFile) {
|
||||||
|
dbBusyForDatabaseCreation.set(true);
|
||||||
|
applyDbBusyLock();
|
||||||
|
showStatusMessage("Neue SQLite-Datenbank wird angelegt …");
|
||||||
|
|
||||||
|
createNewDatabaseExecutor.submit(() -> {
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
|
||||||
|
.CreateNewDatabaseResult result;
|
||||||
|
try {
|
||||||
|
result = createNewDatabasePort.createNewDatabase(loadedConfigurationPath(), targetFile);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("GUI-Editor: Unerwarteter Fehler beim Anlegen der neuen Datenbank: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
dbBusyForDatabaseCreation.set(false);
|
||||||
|
applyDbBusyLock();
|
||||||
|
showError("Neue Datenbank konnte nicht angelegt werden: " + safeMessage(e));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Platform.runLater(() -> handleCreateNewDatabaseResult(targetFile, result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersetzt das Ergebnis des Use-Cases in die UI-Reaktion: Dirty-State,
|
||||||
|
* Statuszeile, Verlauf-Reload, Hinweismeldung oder Fehlerdialog.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer gewählte Zielpfad
|
||||||
|
* @param result das Ergebnis des Use-Cases
|
||||||
|
*/
|
||||||
|
private void handleCreateNewDatabaseResult(
|
||||||
|
Path targetFile,
|
||||||
|
de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase
|
||||||
|
.CreateNewDatabaseResult result) {
|
||||||
|
try {
|
||||||
|
switch (result) {
|
||||||
|
case de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.Success success -> {
|
||||||
|
Path effectiveTarget = success.targetFile();
|
||||||
|
LOG.info("GUI-Editor: Neue Datenbank ist aktiv: {}", effectiveTarget);
|
||||||
|
|
||||||
|
// Konfigurationsmodell aktualisieren → Dirty-State
|
||||||
|
GuiConfigurationValues updated = editorState.values()
|
||||||
|
.withSqliteFile(effectiveTarget.toString());
|
||||||
|
updateValues(updated);
|
||||||
|
|
||||||
|
// Verlauf-Tab neu laden, damit die neue (leere) DB sichtbar wird
|
||||||
|
if (historyTab != null) {
|
||||||
|
historyTab.reloadAfterDatabaseSwitch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hinweismeldung im zentralen Meldungsbereich
|
||||||
|
pendingMessages.add(GuiMessageEntry.of(
|
||||||
|
GuiMessageSeverity.INFO,
|
||||||
|
"Neue Datenbank ist aktiv. Konfiguration speichern, damit "
|
||||||
|
+ "diese DB beim nächsten Start verwendet wird.",
|
||||||
|
"Datenbank-Anlage"));
|
||||||
|
refreshAfterValidation();
|
||||||
|
showStatusMessage("Neue Datenbank ist aktiv: " + effectiveTarget);
|
||||||
|
}
|
||||||
|
case de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.SameAsActiveDatabase same -> {
|
||||||
|
LOG.warn("GUI-Editor: Anlage abgelehnt – Zielpfad ist die aktuell aktive DB: {}",
|
||||||
|
same.targetFile());
|
||||||
|
showError("Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei. "
|
||||||
|
+ "Bitte einen anderen Pfad wählen.");
|
||||||
|
}
|
||||||
|
case de.gecheckt.pdf.umbenenner.application.port.in
|
||||||
|
.CreateNewDatabaseUseCase.CreateNewDatabaseResult.CreationFailed failure -> {
|
||||||
|
LOG.error("GUI-Editor: Anlage fehlgeschlagen ({}): {}",
|
||||||
|
failure.phase(), failure.message());
|
||||||
|
showError("Neue Datenbank konnte nicht angelegt werden: " + failure.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dbBusyForDatabaseCreation.set(false);
|
||||||
|
applyDbBusyLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet die aktuelle DB-Busy-Sperre auf die betroffenen UI-Komponenten an.
|
||||||
|
* <p>
|
||||||
|
* Während der Sperre sind die DB-lesenden und DB-schreibenden Aktionen des
|
||||||
|
* Verlauf-Tabs deaktiviert. Andere DB-Operationen laufen pro Aufruf frisch in
|
||||||
|
* Bootstrap und greifen automatisch den DB-Override des
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort} ab.
|
||||||
|
*/
|
||||||
|
private void applyDbBusyLock() {
|
||||||
|
if (historyTab != null) {
|
||||||
|
historyTab.setDbBusy(dbBusyForDatabaseCreation.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks on a background worker thread whether the target path already exists and, if so,
|
* Checks on a background worker thread whether the target path already exists and, if so,
|
||||||
* asks the user to confirm overwriting on the FX Application Thread before writing.
|
* asks the user to confirm overwriting on the FX Application Thread before writing.
|
||||||
@@ -1145,6 +1524,8 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
refreshHeader();
|
refreshHeader();
|
||||||
// Statuszeile nach erfolgreichem Speichern aktualisieren (Konfigurationspfad kann neu sein)
|
// Statuszeile nach erfolgreichem Speichern aktualisieren (Konfigurationspfad kann neu sein)
|
||||||
statusBarStateListener.accept(this.editorState);
|
statusBarStateListener.accept(this.editorState);
|
||||||
|
// Prompt-Tab über neuen Prompt-Pfad informieren (kann sich durch Speichern geändert haben)
|
||||||
|
notifyPromptTabConfigChanged(this.editorState);
|
||||||
|
|
||||||
if (result.hasApiKeyPreservationNote()) {
|
if (result.hasApiKeyPreservationNote()) {
|
||||||
LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
|
LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
|
||||||
@@ -1179,7 +1560,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
FileChooser fileChooser = saveFileChooserFactory.get();
|
FileChooser fileChooser = saveFileChooserFactory.get();
|
||||||
fileChooser.setTitle("Konfiguration speichern");
|
fileChooser.setTitle("Konfiguration speichern");
|
||||||
fileChooser.getExtensionFilters().add(
|
fileChooser.getExtensionFilters().add(
|
||||||
new FileChooser.ExtensionFilter("Properties-Dateien", "*.properties"));
|
new FileChooser.ExtensionFilter(PROPERTIES_FILTER_DESC, PROPERTIES_FILTER_EXT));
|
||||||
java.io.File proposedDirFile = DEFAULT_SAVE_PATH.getParent().toAbsolutePath().toFile();
|
java.io.File proposedDirFile = DEFAULT_SAVE_PATH.getParent().toAbsolutePath().toFile();
|
||||||
if (proposedDirFile.exists()) {
|
if (proposedDirFile.exists()) {
|
||||||
fileChooser.setInitialDirectory(proposedDirFile);
|
fileChooser.setInitialDirectory(proposedDirFile);
|
||||||
@@ -1242,6 +1623,61 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
runEditorValidation();
|
runEditorValidation();
|
||||||
// Statuszeile über den neuen Zustand informieren
|
// Statuszeile über den neuen Zustand informieren
|
||||||
statusBarStateListener.accept(newState);
|
statusBarStateListener.accept(newState);
|
||||||
|
// Prompt-Tab mit neuem Pfad und Port versorgen
|
||||||
|
notifyPromptTabConfigChanged(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigt den Prompt-Editor-Tab über eine geänderte Konfiguration.
|
||||||
|
* <p>
|
||||||
|
* Liest {@code prompt.template.file} und {@code max.title.length} aus dem neuen
|
||||||
|
* Zustand, erzeugt über die Factory einen passenden Port und übergibt beides an den
|
||||||
|
* Tab. Ist der Prompt-Pfad leer, wird ein No-Op-Port verwendet.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param state der neue Editor-Zustand; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
private void notifyPromptTabConfigChanged(de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState state) {
|
||||||
|
String promptPath = state.values().promptTemplateFile();
|
||||||
|
int maxTitle;
|
||||||
|
try {
|
||||||
|
maxTitle = Integer.parseInt(state.values().maxTitleLength().trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
maxTitle = 60;
|
||||||
|
}
|
||||||
|
GuiPromptEditorPort newPort = (promptPath != null && !promptPath.isBlank())
|
||||||
|
? promptEditorPortFactory.create(promptPath)
|
||||||
|
: noOpPromptEditorPort();
|
||||||
|
promptEditorTab.notifyConfigurationChanged(
|
||||||
|
newPort,
|
||||||
|
promptPath != null ? promptPath : "",
|
||||||
|
maxTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_PATH", NO_PROMPT_PATH_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_PATH_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_PATH_MSG);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void configureRoot() {
|
private void configureRoot() {
|
||||||
@@ -1310,30 +1746,33 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
scrollPane.setPadding(new Insets(0));
|
scrollPane.setPadding(new Insets(0));
|
||||||
editorTab.setContent(scrollPane);
|
editorTab.setContent(scrollPane);
|
||||||
|
|
||||||
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), historyTab.tab(), promptEditorTab.tab());
|
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), schedulerTab.tab(), historyTab.tab(), promptEditorTab.tab());
|
||||||
root.setCenter(tabPane);
|
root.setCenter(tabPane);
|
||||||
|
|
||||||
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
|
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
|
||||||
// der Dateiname-Editor ungespeicherte Änderungen hat.
|
// der Dateiname-Editor ungespeicherte Änderungen hat.
|
||||||
// Gleiches gilt für den Prompt-Tab.
|
// Gleiches gilt für den Prompt-Tab.
|
||||||
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
|
tabPane.getSelectionModel().selectedItemProperty().addListener(
|
||||||
|
(obs, oldTab, newTab) -> handleTabSwitch(oldTab, newTab));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleTabSwitch(javafx.scene.control.Tab oldTab, javafx.scene.control.Tab newTab) {
|
||||||
if (oldTab == null || newTab == null) {
|
if (oldTab == null || newTab == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (oldTab == batchRunTab.tab() && batchRunTab.hasUnsavedFilenameEdits()) {
|
if (oldTab == batchRunTab.tab() && batchRunTab.hasUnsavedFilenameEdits()) {
|
||||||
// Selektion kurz unterdrücken um Rekursion zu vermeiden
|
|
||||||
boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits();
|
boolean shouldDiscard = batchRunTab.confirmDiscardUnsavedFilenameEdits();
|
||||||
if (!shouldDiscard) {
|
if (!shouldDiscard) {
|
||||||
// Zurück zum Verarbeitungslauf-Tab
|
|
||||||
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
||||||
}
|
}
|
||||||
} else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) {
|
} else if (oldTab == promptEditorTab.tab() && promptEditorTab.hasDirtyContent()) {
|
||||||
boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty();
|
boolean shouldDiscard = promptEditorTab.confirmDiscardIfDirty();
|
||||||
if (!shouldDiscard) {
|
if (!shouldDiscard) {
|
||||||
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
Platform.runLater(() -> tabPane.getSelectionModel().select(oldTab));
|
||||||
|
} else {
|
||||||
|
promptEditorTab.discardChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void configureActionBar() {
|
private void configureActionBar() {
|
||||||
@@ -1368,6 +1807,11 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
dirtyMarkerLabel.setVisible(dirty);
|
dirtyMarkerLabel.setVisible(dirty);
|
||||||
dirtyMarkerLabel.setManaged(dirty);
|
dirtyMarkerLabel.setManaged(dirty);
|
||||||
|
|
||||||
|
// Tab-Titel mit Dirty-Indikator aktualisieren (UX-Konsistenz zum Prompt-Tab)
|
||||||
|
if (configurationTab != null) {
|
||||||
|
configurationTab.setText(dirty ? "* Konfiguration" : "Konfiguration");
|
||||||
|
}
|
||||||
|
|
||||||
titleUpdateListener.accept(GuiWindowTitleFormatter.format(editorState));
|
titleUpdateListener.accept(GuiWindowTitleFormatter.format(editorState));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1500,10 +1944,12 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
TextField lockField = boundTextField(
|
TextField lockField = boundTextField(
|
||||||
editorState.values().runtimeLockFile(),
|
editorState.values().runtimeLockFile(),
|
||||||
val -> updateValues(editorState.values().withRuntimeLockFile(val)));
|
val -> updateValues(editorState.values().withRuntimeLockFile(val)));
|
||||||
|
applyTooltip(lockField, GuiTooltipTexts.PFADE_LOCK_DATEI);
|
||||||
|
|
||||||
TextField logDirField = boundTextField(
|
TextField logDirField = boundTextField(
|
||||||
editorState.values().logDirectory(),
|
editorState.values().logDirectory(),
|
||||||
val -> updateValues(editorState.values().withLogDirectory(val)));
|
val -> updateValues(editorState.values().withLogDirectory(val)));
|
||||||
|
applyTooltip(logDirField, GuiTooltipTexts.PFADE_LOG_VERZEICHNIS);
|
||||||
|
|
||||||
VBox optionalContent = new VBox(4);
|
VBox optionalContent = new VBox(4);
|
||||||
optionalContent.setPadding(new Insets(6, 0, 0, 0));
|
optionalContent.setPadding(new Insets(6, 0, 0, 0));
|
||||||
@@ -1563,6 +2009,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
Button reloadModelsButton = new Button("Modelle neu laden");
|
Button reloadModelsButton = new Button("Modelle neu laden");
|
||||||
reloadModelsButton.setId("modelle-neu-laden-button");
|
reloadModelsButton.setId("modelle-neu-laden-button");
|
||||||
reloadModelsButton.setOnAction(event -> triggerModelRetrievalForCurrentProvider(providerComboBox));
|
reloadModelsButton.setOnAction(event -> triggerModelRetrievalForCurrentProvider(providerComboBox));
|
||||||
|
applyTooltip(reloadModelsButton, GuiTooltipTexts.PROVIDER_MODELLE_NEU_LADEN);
|
||||||
|
|
||||||
HBox comboRow = new HBox(8, providerComboBox, reloadModelsButton);
|
HBox comboRow = new HBox(8, providerComboBox, reloadModelsButton);
|
||||||
comboRow.setAlignment(Pos.CENTER_LEFT);
|
comboRow.setAlignment(Pos.CENTER_LEFT);
|
||||||
@@ -1784,6 +2231,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
val, pState2.model(), pState2.timeoutSeconds(), pState2.apiKey())));
|
val, pState2.model(), pState2.timeoutSeconds(), pState2.apiKey())));
|
||||||
Label baseUrlError = createFieldErrorLabel();
|
Label baseUrlError = createFieldErrorLabel();
|
||||||
fieldErrorLabels.put(ns + "baseUrl", baseUrlError);
|
fieldErrorLabels.put(ns + "baseUrl", baseUrlError);
|
||||||
|
applyTooltip(baseUrlField, GuiTooltipTexts.PROVIDER_BASIS_URL);
|
||||||
HBox baseUrlBox = new HBox(4, baseUrlField);
|
HBox baseUrlBox = new HBox(4, baseUrlField);
|
||||||
HBox.setHgrow(baseUrlField, Priority.ALWAYS);
|
HBox.setHgrow(baseUrlField, Priority.ALWAYS);
|
||||||
fieldGrid.add(new Label("Basis-URL:"), 0, gridRow);
|
fieldGrid.add(new Label("Basis-URL:"), 0, gridRow);
|
||||||
@@ -1792,6 +2240,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
TextField timeoutField = boundTextField(pState.timeoutSeconds(),
|
TextField timeoutField = boundTextField(pState.timeoutSeconds(),
|
||||||
val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
|
val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
|
||||||
pState2.baseUrl(), pState2.model(), val, pState2.apiKey())));
|
pState2.baseUrl(), pState2.model(), val, pState2.apiKey())));
|
||||||
|
applyTooltip(timeoutField, GuiTooltipTexts.PROVIDER_TIMEOUT);
|
||||||
Label timeoutError = createFieldErrorLabel();
|
Label timeoutError = createFieldErrorLabel();
|
||||||
fieldErrorLabels.put(ns + "timeoutSeconds", timeoutError);
|
fieldErrorLabels.put(ns + "timeoutSeconds", timeoutError);
|
||||||
fieldGrid.add(new Label("Timeout (Sek.):"), 2, gridRow);
|
fieldGrid.add(new Label("Timeout (Sek.):"), 2, gridRow);
|
||||||
@@ -1850,6 +2299,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
|
val -> updateProviderField(family, pState2 -> new GuiProviderConfigurationState(
|
||||||
pState2.baseUrl(), pState2.model(), pState2.timeoutSeconds(),
|
pState2.baseUrl(), pState2.model(), pState2.timeoutSeconds(),
|
||||||
GuiProviderApiKeyState.unresolved(val))));
|
GuiProviderApiKeyState.unresolved(val))));
|
||||||
|
applyTooltip(apiKeyField, GuiTooltipTexts.PROVIDER_API_KEY);
|
||||||
Label apiKeyError = createFieldErrorLabel();
|
Label apiKeyError = createFieldErrorLabel();
|
||||||
fieldErrorLabels.put(ns + "apiKey", apiKeyError);
|
fieldErrorLabels.put(ns + "apiKey", apiKeyError);
|
||||||
Label apiKeyOriginLabel = createApiKeyOriginLabel();
|
Label apiKeyOriginLabel = createApiKeyOriginLabel();
|
||||||
@@ -1941,6 +2391,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
TextField maxRetriesField = boundTextField(
|
TextField maxRetriesField = boundTextField(
|
||||||
editorState.values().maxRetriesTransient(),
|
editorState.values().maxRetriesTransient(),
|
||||||
val -> updateValues(editorState.values().withMaxRetriesTransient(val)));
|
val -> updateValues(editorState.values().withMaxRetriesTransient(val)));
|
||||||
|
applyTooltip(maxRetriesField, GuiTooltipTexts.LIMITS_MAX_RETRIES);
|
||||||
grid.add(new Label("Max. Retries:"), 2, row);
|
grid.add(new Label("Max. Retries:"), 2, row);
|
||||||
grid.add(maxRetriesField, 3, row);
|
grid.add(maxRetriesField, 3, row);
|
||||||
row++;
|
row++;
|
||||||
@@ -1949,6 +2400,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
TextField logLevelField = boundTextField(
|
TextField logLevelField = boundTextField(
|
||||||
editorState.values().logLevel(),
|
editorState.values().logLevel(),
|
||||||
val -> updateValues(editorState.values().withLogLevel(val)));
|
val -> updateValues(editorState.values().withLogLevel(val)));
|
||||||
|
applyTooltip(logLevelField, GuiTooltipTexts.LIMITS_LOG_LEVEL);
|
||||||
grid.add(new Label("Log-Level:"), 0, row);
|
grid.add(new Label("Log-Level:"), 0, row);
|
||||||
grid.add(logLevelField, 1, row);
|
grid.add(logLevelField, 1, row);
|
||||||
|
|
||||||
@@ -1958,6 +2410,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
sensitiveCheck.setSelected(sensitive);
|
sensitiveCheck.setSelected(sensitive);
|
||||||
sensitiveCheck.selectedProperty().addListener((obs, oldVal, newVal) ->
|
sensitiveCheck.selectedProperty().addListener((obs, oldVal, newVal) ->
|
||||||
updateValues(editorState.values().withLogAiSensitive(Boolean.toString(newVal))));
|
updateValues(editorState.values().withLogAiSensitive(Boolean.toString(newVal))));
|
||||||
|
applyTooltip(sensitiveCheck, GuiTooltipTexts.LIMITS_SENSIBLE_KI_AUSGABE);
|
||||||
grid.add(new Label(), 2, row);
|
grid.add(new Label(), 2, row);
|
||||||
grid.add(sensitiveCheck, 3, row);
|
grid.add(sensitiveCheck, 3, row);
|
||||||
|
|
||||||
@@ -2088,6 +2541,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
card.getChildren().add(messagesListView);
|
card.getChildren().add(messagesListView);
|
||||||
|
|
||||||
clearMessagesButton.setOnAction(e -> clearMessages());
|
clearMessagesButton.setOnAction(e -> clearMessages());
|
||||||
|
applyTooltip(clearMessagesButton, GuiTooltipTexts.TOOLBAR_MELDUNGEN_LEEREN);
|
||||||
HBox clearButtonRow = new HBox(clearMessagesButton);
|
HBox clearButtonRow = new HBox(clearMessagesButton);
|
||||||
clearButtonRow.setAlignment(Pos.CENTER_LEFT);
|
clearButtonRow.setAlignment(Pos.CENTER_LEFT);
|
||||||
card.getChildren().add(clearButtonRow);
|
card.getChildren().add(clearButtonRow);
|
||||||
@@ -2213,7 +2667,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
|
|
||||||
for (EditorValidationFinding finding : report.findings()) {
|
for (EditorValidationFinding finding : report.findings()) {
|
||||||
GuiMessageSeverity severity = toGuiSeverity(finding.severity());
|
GuiMessageSeverity severity = toGuiSeverity(finding.severity());
|
||||||
messages.add(GuiMessageEntry.of(severity, finding.message(), "Validierung"));
|
messages.add(GuiMessageEntry.of(severity, finding.message(), OPERATION_VALIDATE));
|
||||||
if (finding.hasFieldKey()) {
|
if (finding.hasFieldKey()) {
|
||||||
fieldFindings.add(new GuiFieldFinding(finding.fieldKey().orElseThrow(),
|
fieldFindings.add(new GuiFieldFinding(finding.fieldKey().orElseThrow(),
|
||||||
severity, finding.message()));
|
severity, finding.message()));
|
||||||
@@ -2222,7 +2676,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
|
|
||||||
// Replace validation-related entries; preserve model-catalog messages (from coordinator)
|
// Replace validation-related entries; preserve model-catalog messages (from coordinator)
|
||||||
pendingMessages.removeIf(m -> m.source().isPresent()
|
pendingMessages.removeIf(m -> m.source().isPresent()
|
||||||
&& "Validierung".equals(m.source().get()));
|
&& OPERATION_VALIDATE.equals(m.source().get()));
|
||||||
pendingMessages.addAll(messages);
|
pendingMessages.addAll(messages);
|
||||||
|
|
||||||
pendingFieldFindings.clear();
|
pendingFieldFindings.clear();
|
||||||
@@ -2278,7 +2732,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
// Drop silent auto-validation entries so the central message area is not flooded
|
// Drop silent auto-validation entries so the central message area is not flooded
|
||||||
// by keystroke-level background checks; explicit action entries always accumulate.
|
// by keystroke-level background checks; explicit action entries always accumulate.
|
||||||
pendingMessages.removeIf(m -> m.source().isPresent()
|
pendingMessages.removeIf(m -> m.source().isPresent()
|
||||||
&& "Validierung".equals(m.source().get()));
|
&& OPERATION_VALIDATE.equals(m.source().get()));
|
||||||
|
|
||||||
// Append a timestamped confirmation plus each concrete finding as its own entry.
|
// Append a timestamped confirmation plus each concrete finding as its own entry.
|
||||||
int findingCount = report.findings().size();
|
int findingCount = report.findings().size();
|
||||||
@@ -2733,6 +3187,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
Button pickButton1 = new Button("…");
|
Button pickButton1 = new Button("…");
|
||||||
pickButton1.setOnAction(e -> onPick1.run());
|
pickButton1.setOnAction(e -> onPick1.run());
|
||||||
pickButton1.setMinWidth(32);
|
pickButton1.setMinWidth(32);
|
||||||
|
applyTooltip(pickButton1, GuiTooltipTexts.PFADE_BROWSER_BUTTON);
|
||||||
HBox fieldBox1 = new HBox(4, field1, pickButton1);
|
HBox fieldBox1 = new HBox(4, field1, pickButton1);
|
||||||
HBox.setHgrow(field1, Priority.ALWAYS);
|
HBox.setHgrow(field1, Priority.ALWAYS);
|
||||||
fieldBox1.setAlignment(Pos.CENTER_LEFT);
|
fieldBox1.setAlignment(Pos.CENTER_LEFT);
|
||||||
@@ -2760,6 +3215,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
Button pickButton2 = new Button("…");
|
Button pickButton2 = new Button("…");
|
||||||
pickButton2.setOnAction(e -> onPick2.run());
|
pickButton2.setOnAction(e -> onPick2.run());
|
||||||
pickButton2.setMinWidth(32);
|
pickButton2.setMinWidth(32);
|
||||||
|
applyTooltip(pickButton2, GuiTooltipTexts.PFADE_BROWSER_BUTTON);
|
||||||
HBox fieldBox2 = new HBox(4, field2, pickButton2);
|
HBox fieldBox2 = new HBox(4, field2, pickButton2);
|
||||||
HBox.setHgrow(field2, Priority.ALWAYS);
|
HBox.setHgrow(field2, Priority.ALWAYS);
|
||||||
fieldBox2.setAlignment(Pos.CENTER_LEFT);
|
fieldBox2.setAlignment(Pos.CENTER_LEFT);
|
||||||
@@ -2915,6 +3371,20 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
: exception.getMessage();
|
: exception.getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt die Dateiendung der aktiven SQLite-Konfigurationsdatei.
|
||||||
|
* Gibt {@code ".db"} zurück, wenn der Pfad auf {@code .db} endet, sonst {@code ".sqlite"}.
|
||||||
|
*
|
||||||
|
* @param sqliteFile konfigurierter SQLite-Pfad, darf {@code null} oder leer sein
|
||||||
|
* @return {@code ".db"} oder {@code ".sqlite"}
|
||||||
|
*/
|
||||||
|
private static String resolveDbExtension(String sqliteFile) {
|
||||||
|
if (sqliteFile != null && sqliteFile.toLowerCase().endsWith(".db")) {
|
||||||
|
return ".db";
|
||||||
|
}
|
||||||
|
return ".sqlite";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf das angegebene Control.
|
* Setzt einen Tooltip mit einheitlicher Anzeigeverzögerung auf das angegebene Control.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -2940,7 +3410,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
private void saveLastConfigurationPath(Path configFilePath) {
|
private void saveLastConfigurationPath(Path configFilePath) {
|
||||||
try {
|
try {
|
||||||
Preferences prefs = Preferences.userNodeForPackage(GuiConfigurationEditorWorkspace.class);
|
Preferences prefs = Preferences.userRoot().node("de/gecheckt/pdf-umbenenner");
|
||||||
prefs.put("lastConfigPath", configFilePath.toAbsolutePath().toString());
|
prefs.put("lastConfigPath", configFilePath.toAbsolutePath().toString());
|
||||||
LOG.debug("GUI-Editor: Letzter Konfigurationspfad gespeichert: {}", configFilePath);
|
LOG.debug("GUI-Editor: Letzter Konfigurationspfad gespeichert: {}", configFilePath);
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
@@ -2955,7 +3425,7 @@ public final class GuiConfigurationEditorWorkspace {
|
|||||||
*/
|
*/
|
||||||
public void autoLoadLastConfiguration() {
|
public void autoLoadLastConfiguration() {
|
||||||
try {
|
try {
|
||||||
Preferences prefs = Preferences.userNodeForPackage(GuiConfigurationEditorWorkspace.class);
|
Preferences prefs = Preferences.userRoot().node("de/gecheckt/pdf-umbenenner");
|
||||||
String lastPath = prefs.get("lastConfigPath", null);
|
String lastPath = prefs.get("lastConfigPath", null);
|
||||||
|
|
||||||
if (lastPath == null) {
|
if (lastPath == null) {
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
+12
-8
@@ -51,6 +51,10 @@ import javafx.application.Platform;
|
|||||||
* {@code Platform.runLater}.
|
* {@code Platform.runLater}.
|
||||||
*/
|
*/
|
||||||
public final class GuiModelCatalogCoordinator {
|
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);
|
private static final Logger LOG = LogManager.getLogger(GuiModelCatalogCoordinator.class);
|
||||||
|
|
||||||
@@ -203,7 +207,7 @@ public final class GuiModelCatalogCoordinator {
|
|||||||
String previousManualValue) {
|
String previousManualValue) {
|
||||||
// Remove any previous message entries from an earlier retrieval so messages do not
|
// Remove any previous message entries from an earlier retrieval so messages do not
|
||||||
// accumulate across repeated triggers of the same retrieval action.
|
// accumulate across repeated triggers of the same retrieval action.
|
||||||
pendingMessages.removeIf(msg -> "Modellabruf".equals(msg.source().orElse("")));
|
pendingMessages.removeIf(msg -> OPERATION_MODELLABRUF.equals(msg.source().orElse("")));
|
||||||
|
|
||||||
String displayName = displayNameFor(family);
|
String displayName = displayNameFor(family);
|
||||||
|
|
||||||
@@ -213,28 +217,28 @@ public final class GuiModelCatalogCoordinator {
|
|||||||
container.applyModelList(models, previousManualValue);
|
container.applyModelList(models, previousManualValue);
|
||||||
String message = "Modellliste für " + displayName + " geladen ("
|
String message = "Modellliste für " + displayName + " geladen ("
|
||||||
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
|
+ models.size() + " " + (models.size() == 1 ? "Eintrag" : "Einträge") + ").";
|
||||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, "Modellabruf"));
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.INFO, message, OPERATION_MODELLABRUF));
|
||||||
LOG.info("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
LOG.info(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
|
||||||
}
|
}
|
||||||
case ModelCatalogResult.EmptyList emptyList -> {
|
case ModelCatalogResult.EmptyList emptyList -> {
|
||||||
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||||
String message = "Provider " + displayName
|
String message = "Provider " + displayName
|
||||||
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
|
+ " liefert aktuell keine Modelle. Manuelle Eingabe aktiv.";
|
||||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, "Modellabruf"));
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.HINT, message, OPERATION_MODELLABRUF));
|
||||||
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
|
||||||
}
|
}
|
||||||
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
|
case ModelCatalogResult.IncompleteConfiguration incomplete -> {
|
||||||
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
container.applyManualFallback(GuiModelSource.LIST_UNAVAILABLE_MANUAL_INPUT);
|
||||||
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
|
String message = "Modellliste nicht abrufbar: " + incomplete.missingReason()
|
||||||
+ ". Manuelle Eingabe aktiv.";
|
+ ". Manuelle Eingabe aktiv.";
|
||||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, "Modellabruf"));
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.WARNING, message, OPERATION_MODELLABRUF));
|
||||||
LOG.warn("GUI-Modellabruf: {} (Provider: {})", message, family.getIdentifier());
|
LOG.warn(LOG_MODEL_FETCH_FMT, message, family.getIdentifier());
|
||||||
}
|
}
|
||||||
case ModelCatalogResult.TechnicalFailure failure -> {
|
case ModelCatalogResult.TechnicalFailure failure -> {
|
||||||
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
|
container.applyManualFallback(GuiModelSource.LIST_FAILED_MANUAL_INPUT);
|
||||||
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
|
String message = "Modellliste nicht abrufbar (" + failure.errorCategory()
|
||||||
+ "). Manuelle Eingabe aktiv.";
|
+ "). Manuelle Eingabe aktiv.";
|
||||||
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, "Modellabruf"));
|
pendingMessages.add(GuiMessageEntry.of(GuiMessageSeverity.ERROR, message, OPERATION_MODELLABRUF));
|
||||||
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
|
LOG.warn("GUI-Modellabruf: {} Detail: {} (Provider: {})",
|
||||||
message, failure.errorDetail(), family.getIdentifier());
|
message, failure.errorDetail(), family.getIdentifier());
|
||||||
}
|
}
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
+55
-7
@@ -58,11 +58,11 @@ public class GuiPromptEditorTab {
|
|||||||
private static final String TAB_TITLE = "Prompt";
|
private static final String TAB_TITLE = "Prompt";
|
||||||
private static final String TAB_TITLE_DIRTY = "Prompt *";
|
private static final String TAB_TITLE_DIRTY = "Prompt *";
|
||||||
|
|
||||||
private final GuiPromptEditorPort promptEditorPort;
|
private GuiPromptEditorPort promptEditorPort;
|
||||||
/** Konfigurierter Prompt-Dateipfad – wird für CreatePromptFile-Vorschläge benötigt. */
|
/** Konfigurierter Prompt-Dateipfad – wird für CreatePromptFile-Vorschläge benötigt. */
|
||||||
private final String configuredPromptPath;
|
private String configuredPromptPath;
|
||||||
/** Konfigurierte maximale Titellänge – für den Default-Prompt-Inhalt. */
|
/** Konfigurierte maximale Titellänge – für den Default-Prompt-Inhalt. */
|
||||||
private final int maxTitleLength;
|
private int maxTitleLength;
|
||||||
|
|
||||||
// Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
|
// Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
|
||||||
/** Erzeugt Worker-Threads für blockierende Operationen. */
|
/** Erzeugt Worker-Threads für blockierende Operationen. */
|
||||||
@@ -125,6 +125,54 @@ public class GuiPromptEditorTab {
|
|||||||
return dirty;
|
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.
|
* Zeigt einen Bestätigungsdialog, wenn ungespeicherte Änderungen vorhanden sind.
|
||||||
* Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen.
|
* Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen.
|
||||||
@@ -177,6 +225,7 @@ public class GuiPromptEditorTab {
|
|||||||
textArea.setWrapText(true);
|
textArea.setWrapText(true);
|
||||||
textArea.setFont(Font.font("Monospace", 13));
|
textArea.setFont(Font.font("Monospace", 13));
|
||||||
textArea.setPrefRowCount(20);
|
textArea.setPrefRowCount(20);
|
||||||
|
textArea.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_TEXTAREA));
|
||||||
VBox.setVgrow(textArea, Priority.ALWAYS);
|
VBox.setVgrow(textArea, Priority.ALWAYS);
|
||||||
|
|
||||||
// Dirty-State-Tracking
|
// Dirty-State-Tracking
|
||||||
@@ -195,14 +244,13 @@ public class GuiPromptEditorTab {
|
|||||||
statusLabel.setStyle("-fx-text-fill: #555555;");
|
statusLabel.setStyle("-fx-text-fill: #555555;");
|
||||||
|
|
||||||
// Buttons verdrahten
|
// Buttons verdrahten
|
||||||
saveButton.setTooltip(new Tooltip("Prompt-Datei speichern (atomar, UTF-8)."));
|
saveButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_SPEICHERN));
|
||||||
saveButton.setOnAction(e -> requestSave());
|
saveButton.setOnAction(e -> requestSave());
|
||||||
|
|
||||||
resetButton.setTooltip(new Tooltip("Textfeld mit dem Standard-Prompt-Inhalt befüllen, ohne zu speichern."));
|
resetButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_ZURUECKSETZEN));
|
||||||
resetButton.setOnAction(e -> resetToDefault());
|
resetButton.setOnAction(e -> resetToDefault());
|
||||||
|
|
||||||
createDefaultButton.setTooltip(new Tooltip(
|
createDefaultButton.setTooltip(new Tooltip(GuiTooltipTexts.PROMPT_STANDARD_ANLEGEN));
|
||||||
"Standard-Prompt-Datei am konfigurierten Pfad anlegen."));
|
|
||||||
createDefaultButton.setOnAction(e -> requestCreateDefault());
|
createDefaultButton.setOnAction(e -> requestCreateDefault());
|
||||||
createDefaultButton.setVisible(false);
|
createDefaultButton.setVisible(false);
|
||||||
createDefaultButton.setManaged(false);
|
createDefaultButton.setManaged(false);
|
||||||
|
|||||||
+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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+229
-15
@@ -18,6 +18,8 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocument
|
|||||||
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.application.port.in.ResetDocumentStatusResult;
|
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.AiModelCatalogPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
|
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.editor.ApiKeyResolutionPort;
|
||||||
@@ -48,8 +50,29 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
|||||||
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
|
* the {@link GuiManualFileCopyPort} used to manually copy a source file to the target
|
||||||
* folder for documents that have not yet been successfully processed, and
|
* folder for documents that have not yet been successfully processed, and
|
||||||
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
|
* the {@link GuiHistoricalDocumentContextPort} used to retrieve the historical processing
|
||||||
* context for documents that were skipped in the current run, and the resolved application
|
* 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.
|
* 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>
|
* <p>
|
||||||
* All ports and services are supplied by Bootstrap so that the GUI adapter does not need to
|
* 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.
|
* know about provider-specific HTTP details or adapter wiring.
|
||||||
@@ -76,7 +99,16 @@ public record GuiStartupContext(
|
|||||||
GuiHistoryOverviewPort historyOverviewPort,
|
GuiHistoryOverviewPort historyOverviewPort,
|
||||||
GuiHistoryDetailsPort historyDetailsPort,
|
GuiHistoryDetailsPort historyDetailsPort,
|
||||||
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
|
||||||
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort) {
|
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
|
||||||
|
GuiPromptEditorPortFactory promptEditorPortFactory,
|
||||||
|
GuiCreateNewDatabasePort createNewDatabasePort,
|
||||||
|
Optional<String> applicationContextError,
|
||||||
|
Optional<SchedulerControlUseCase> schedulerControlUseCase,
|
||||||
|
Optional<ConfigurationFileLockPort> configurationFileLockPort,
|
||||||
|
GuiApplicationContextInitializer applicationContextInitializer) {
|
||||||
|
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 fully wired startup context.
|
* Creates a fully wired startup context.
|
||||||
@@ -107,10 +139,15 @@ public record GuiStartupContext(
|
|||||||
* bar; {@code null} defaults to {@code "dev"}
|
* bar; {@code null} defaults to {@code "dev"}
|
||||||
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
|
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
|
||||||
* {@code null} sein
|
* {@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,
|
||||||
@@ -150,6 +187,155 @@ public record GuiStartupContext(
|
|||||||
"historyResetDocumentStatusPort must not be null");
|
"historyResetDocumentStatusPort must not be null");
|
||||||
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
|
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
|
||||||
"deleteDocumentHistoryPort must not be null");
|
"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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,7 +379,8 @@ public record GuiStartupContext(
|
|||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
noOpHistoryResetPort(), noOpDeleteHistoryPort());
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort(), Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,7 +418,8 @@ public record GuiStartupContext(
|
|||||||
rejectingManualFileCopyPort(),
|
rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
noOpHistoryResetPort(), noOpDeleteHistoryPort());
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort(), Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -269,7 +457,8 @@ public record GuiStartupContext(
|
|||||||
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
|
||||||
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
|
||||||
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
|
||||||
noOpHistoryResetPort(), noOpDeleteHistoryPort());
|
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory(),
|
||||||
|
rejectingCreateNewDatabasePort(), Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
|
||||||
@@ -339,28 +528,29 @@ public record GuiStartupContext(
|
|||||||
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator noOpOrchestrator = new TechnicalTestOrchestrator(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
new de.gecheckt.pdf.umbenenner.application.validation.editor.EditorConfigurationValidator(),
|
||||||
noOpPathCheckPort,
|
noOpPathCheckPort,
|
||||||
noOpTestService);
|
noOpTestService,
|
||||||
|
() -> java.util.Optional.empty());
|
||||||
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
|
ResourceCreationPort noOpResourceCreationPort = new ResourceCreationPort() {
|
||||||
@Override
|
@Override
|
||||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
.CorrectionSuggestion.CreateDirectory suggestion) {
|
.CorrectionSuggestion.CreateDirectory suggestion) {
|
||||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
|
||||||
}
|
}
|
||||||
@Override
|
@Override
|
||||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
createPromptFile(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
|
||||||
}
|
}
|
||||||
@Override
|
@Override
|
||||||
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
|
||||||
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
prepareSqlitePath(de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
.CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
.CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
.CorrectionOutcome.NotAttempted(suggestion, "Kein Port in diesem Startkontext.");
|
.CorrectionOutcome.NotAttempted(suggestion, NO_PORT_MSG);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
|
CorrectionExecutionService noOpCorrectionService = new CorrectionExecutionService(noOpResourceCreationPort);
|
||||||
@@ -389,7 +579,31 @@ public record GuiStartupContext(
|
|||||||
noOpHistoryOverviewPort(),
|
noOpHistoryOverviewPort(),
|
||||||
noOpHistoryDetailsPort(),
|
noOpHistoryDetailsPort(),
|
||||||
noOpHistoryResetPort(),
|
noOpHistoryResetPort(),
|
||||||
noOpDeleteHistoryPort());
|
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() {
|
private static GuiPromptEditorPort noOpPromptEditorPort() {
|
||||||
@@ -397,13 +611,13 @@ public record GuiStartupContext(
|
|||||||
@Override
|
@Override
|
||||||
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
|
||||||
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
|
||||||
"NO_OP", "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
|
"NO_OP", NO_PROMPT_PORT_MSG);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
|
||||||
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
|
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
|
||||||
"Kein Prompt-Editor-Port in diesem Startkontext verfügbar.", null);
|
NO_PROMPT_PORT_MSG, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -413,7 +627,7 @@ public record GuiStartupContext(
|
|||||||
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
.CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
|
||||||
.CorrectionOutcome.NotAttempted(
|
.CorrectionOutcome.NotAttempted(
|
||||||
suggestion, "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.");
|
suggestion, NO_PROMPT_PORT_MSG);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -29,6 +29,9 @@ import javafx.scene.layout.Region;
|
|||||||
* Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung.
|
* Die Klasse selbst erzwingt dies nicht; der Aufrufer trägt die Verantwortung.
|
||||||
*/
|
*/
|
||||||
public final class GuiStatusBar {
|
public final class GuiStatusBar {
|
||||||
|
private static final String LABEL_STYLE = "-fx-font-size: 11px; -fx-text-fill: #555555;";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** Anzeigetext wenn keine Konfiguration geladen ist. */
|
/** Anzeigetext wenn keine Konfiguration geladen ist. */
|
||||||
static final String KEIN_PROFIL_TEXT = "Kein Profil geladen";
|
static final String KEIN_PROFIL_TEXT = "Kein Profil geladen";
|
||||||
@@ -58,16 +61,16 @@ public final class GuiStatusBar {
|
|||||||
|
|
||||||
// Linkes Segment: Versionsanzeige
|
// Linkes Segment: Versionsanzeige
|
||||||
this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion);
|
this.versionLabel = new Label(VERSION_PREFIX + this.applicationVersion);
|
||||||
this.versionLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
|
this.versionLabel.setStyle(LABEL_STYLE);
|
||||||
|
|
||||||
// Mittleres Segment: Provider und Modell
|
// Mittleres Segment: Provider und Modell
|
||||||
this.providerLabel = new Label(KEIN_PROFIL_TEXT);
|
this.providerLabel = new Label(KEIN_PROFIL_TEXT);
|
||||||
this.providerLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
|
this.providerLabel.setStyle(LABEL_STYLE);
|
||||||
this.providerLabel.setAlignment(Pos.CENTER);
|
this.providerLabel.setAlignment(Pos.CENTER);
|
||||||
|
|
||||||
// Rechtes Segment: Konfigurationspfad
|
// Rechtes Segment: Konfigurationspfad
|
||||||
this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
|
this.configPathLabel = new Label(KEIN_PROFIL_TEXT);
|
||||||
this.configPathLabel.setStyle("-fx-font-size: 11px; -fx-text-fill: #555555;");
|
this.configPathLabel.setStyle(LABEL_STYLE);
|
||||||
this.configPathLabel.setAlignment(Pos.CENTER_RIGHT);
|
this.configPathLabel.setAlignment(Pos.CENTER_RIGHT);
|
||||||
|
|
||||||
// Abstandhalter zwischen den Segmenten
|
// Abstandhalter zwischen den Segmenten
|
||||||
|
|||||||
+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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -64,6 +64,7 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
private final TechnicalTestOrchestrator orchestrator;
|
private final TechnicalTestOrchestrator orchestrator;
|
||||||
private final Supplier<EditorValidationInput> inputProvider;
|
private final Supplier<EditorValidationInput> inputProvider;
|
||||||
private final Supplier<String> configFilePathProvider;
|
private final Supplier<String> configFilePathProvider;
|
||||||
|
private final Supplier<String> logDirectoryProvider;
|
||||||
private final List<GuiMessageEntry> pendingMessages;
|
private final List<GuiMessageEntry> pendingMessages;
|
||||||
private final Consumer<TechnicalTestReport> postResultCallback;
|
private final Consumer<TechnicalTestReport> postResultCallback;
|
||||||
|
|
||||||
@@ -89,6 +90,9 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
|
* @param configFilePathProvider Lieferant des aktuell geladenen Konfigurationsdateipfads als String;
|
||||||
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
|
* gibt eine leere Zeichenkette zurück wenn keine Datei geladen ist;
|
||||||
* darf nicht {@code null} sein
|
* 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 pendingMessages geteilte veränderliche Nachrichtenliste; darf nicht {@code null} sein
|
||||||
* @param postResultCallback Callback nach erfolgreicher Ergebnisanwendung; 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
|
* @throws NullPointerException wenn einer der Parameter {@code null} ist
|
||||||
@@ -96,11 +100,13 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
|
public GuiTechnicalTestCoordinator(TechnicalTestOrchestrator orchestrator,
|
||||||
Supplier<EditorValidationInput> inputProvider,
|
Supplier<EditorValidationInput> inputProvider,
|
||||||
Supplier<String> configFilePathProvider,
|
Supplier<String> configFilePathProvider,
|
||||||
|
Supplier<String> logDirectoryProvider,
|
||||||
List<GuiMessageEntry> pendingMessages,
|
List<GuiMessageEntry> pendingMessages,
|
||||||
Consumer<TechnicalTestReport> postResultCallback) {
|
Consumer<TechnicalTestReport> postResultCallback) {
|
||||||
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
|
this.orchestrator = Objects.requireNonNull(orchestrator, "orchestrator must not be null");
|
||||||
this.inputProvider = Objects.requireNonNull(inputProvider, "inputProvider 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.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.pendingMessages = Objects.requireNonNull(pendingMessages, "pendingMessages must not be null");
|
||||||
this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
|
this.postResultCallback = Objects.requireNonNull(postResultCallback, "postResultCallback must not be null");
|
||||||
this.testThreadFactory = task -> {
|
this.testThreadFactory = task -> {
|
||||||
@@ -134,7 +140,8 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
pendingMessages.clear();
|
pendingMessages.clear();
|
||||||
EditorValidationInput input = inputProvider.get();
|
EditorValidationInput input = inputProvider.get();
|
||||||
String configFilePath = configFilePathProvider.get();
|
String configFilePath = configFilePathProvider.get();
|
||||||
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath);
|
String logDirectory = logDirectoryProvider.get();
|
||||||
|
TechnicalTestRequest request = new TechnicalTestRequest(input, configFilePath, logDirectory);
|
||||||
|
|
||||||
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
|
LOG.info("GUI-Gesamttest: Technische Tests ausführen gestartet.");
|
||||||
|
|
||||||
@@ -234,6 +241,7 @@ public final class GuiTechnicalTestCoordinator {
|
|||||||
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
|
case SOURCE_FOLDER_PRESENT -> "Quellordner vorhanden und lesbar";
|
||||||
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
|
case TARGET_FOLDER_USABLE -> "Zielordner vorhanden oder anlegbar sowie schreibbar";
|
||||||
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
|
case SQLITE_PATH_USABLE -> "SQLite-Pfad technisch nutzbar";
|
||||||
|
case LOG_DIRECTORY_USABLE -> "Log-Verzeichnis beschreibbar";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+184
@@ -65,6 +65,14 @@ public final class GuiTooltipTexts {
|
|||||||
public static final String PFADE_PROMPT =
|
public static final String PFADE_PROMPT =
|
||||||
"Externe Textdatei mit den KI-Anweisungen.";
|
"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
|
// Konfigurationstab – Provider
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -77,6 +85,18 @@ public final class GuiTooltipTexts {
|
|||||||
public static final String PROVIDER_MODELL =
|
public static final String PROVIDER_MODELL =
|
||||||
"Das konkrete Sprachmodell des gewählten Providers.";
|
"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
|
// Konfigurationstab – Verarbeitungslimits
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -93,10 +113,26 @@ public final class GuiTooltipTexts {
|
|||||||
public static final String LIMITS_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.";
|
"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
|
// 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". */
|
/** Tooltip für den Button „Dateiname übernehmen". */
|
||||||
public static final String DATEINAME_UEBERNEHMEN =
|
public static final String DATEINAME_UEBERNEHMEN =
|
||||||
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.";
|
"Benennt die Zieldatei um und aktualisiert die Datenbank. Nicht rückgängig zu machen.";
|
||||||
@@ -105,6 +141,154 @@ public final class GuiTooltipTexts {
|
|||||||
public static final String DATEINAME_ZURUECKSETZEN =
|
public static final String DATEINAME_ZURUECKSETZEN =
|
||||||
"Stellt den KI-generierten Namen wieder her, ohne zu speichern.";
|
"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. */
|
/** Nicht instanziierbar – reine Konstantenklasse. */
|
||||||
private GuiTooltipTexts() {
|
private GuiTooltipTexts() {
|
||||||
throw new UnsupportedOperationException("Nicht instanziierbar");
|
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||||
|
|||||||
+107
-4
@@ -7,6 +7,10 @@ import javafx.application.Application;
|
|||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.event.EventHandler;
|
import javafx.event.EventHandler;
|
||||||
import javafx.scene.Scene;
|
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.image.Image;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
@@ -26,6 +30,10 @@ import javafx.stage.WindowEvent;
|
|||||||
*
|
*
|
||||||
* <p>Beim Schließen des Fensters wird die Anwendung in den Windows System-Tray minimiert.
|
* <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.
|
* Ü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 {
|
||||||
|
|
||||||
@@ -34,6 +42,9 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
private static final double DEFAULT_HEIGHT = 800;
|
private static final double DEFAULT_HEIGHT = 800;
|
||||||
|
|
||||||
private SystemTrayManager trayManager;
|
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.
|
||||||
@@ -49,6 +60,8 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
* 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. Installs the close-request handler that
|
* causes an immediate window-title refresh. Installs the close-request handler that
|
||||||
* guards unsaved changes and minimizes the window to the system tray instead of closing.
|
* 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}
|
||||||
*/
|
*/
|
||||||
@@ -64,18 +77,22 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
new Image(getClass().getResourceAsStream("/icons/Icon128.png"))
|
new Image(getClass().getResourceAsStream("/icons/Icon128.png"))
|
||||||
);
|
);
|
||||||
|
|
||||||
GuiStartupContext startupContext = GuiStartupContextHolder.currentOrBlank();
|
guiStartupContext = GuiStartupContextHolder.currentOrBlank();
|
||||||
GuiConfigurationEditorWorkspace workspace = new GuiConfigurationEditorWorkspace(startupContext);
|
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;
|
||||||
|
|
||||||
// Statuszeile anlegen und mit dem Workspace verdrahten
|
// Statuszeile anlegen und mit dem Workspace verdrahten
|
||||||
GuiStatusBar statusBar = new GuiStatusBar(startupContext.applicationVersion());
|
GuiStatusBar statusBar = new GuiStatusBar(guiStartupContext.applicationVersion());
|
||||||
workspace.statusBarStateListener = statusBar::applyEditorState;
|
workspace.statusBarStateListener = statusBar::applyEditorState;
|
||||||
|
|
||||||
|
// Menüleiste mit Datenbank-Menü („Neue Datenbank anlegen…")
|
||||||
|
MenuBar menuBar = buildMenuBar(workspace);
|
||||||
|
|
||||||
// Statuszeile unterhalb des Workspace-Inhalts einbetten
|
// Statuszeile unterhalb des Workspace-Inhalts einbetten
|
||||||
BorderPane outerLayout = new BorderPane();
|
BorderPane outerLayout = new BorderPane();
|
||||||
|
outerLayout.setTop(menuBar);
|
||||||
outerLayout.setCenter(workspace.root());
|
outerLayout.setCenter(workspace.root());
|
||||||
outerLayout.setBottom(statusBar.root());
|
outerLayout.setBottom(statusBar.root());
|
||||||
|
|
||||||
@@ -93,28 +110,114 @@ public class PdfUmbenennerGuiApplication extends Application {
|
|||||||
installTrayCloseHandler(primaryStage, workspace);
|
installTrayCloseHandler(primaryStage, workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scheduler-Close-Guard als äußerste Schicht: verhindert Beenden während Scheduler aktiv
|
||||||
|
installSchedulerCloseGuard(primaryStage);
|
||||||
|
|
||||||
primaryStage.setMaximized(true);
|
primaryStage.setMaximized(true);
|
||||||
primaryStage.show();
|
primaryStage.show();
|
||||||
|
|
||||||
// Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden.
|
// Versuche, die zuletzt geladene Konfigurationsdatei automatisch zu laden.
|
||||||
workspace.autoLoadLastConfiguration();
|
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>
|
||||||
* Entfernt das System-Tray-Icon und loggt das Beenden.
|
* 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) {
|
if (trayManager != null) {
|
||||||
trayManager.remove();
|
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
|
* Legt einen Close-Request-Handler an, der bei sauberem Zustand das Fenster in den
|
||||||
* System-Tray minimiert statt es zu schließen.
|
* System-Tray minimiert statt es zu schließen.
|
||||||
|
|||||||
+5
-3
@@ -2,7 +2,7 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Übersetzt strukturierte Fehlermeldungen aus der Anwendungsschicht in
|
* Übersetzt strukturierte Fehlermeldungen aus der Anwendungsschicht in
|
||||||
* benutzerfreundliche deutsche Texte für den Detailbereich des Verarbeitungslauf-Tabs.
|
* benutzerfreundliche deutsche Texte für die Darstellungsschicht der GUI.
|
||||||
* <p>
|
* <p>
|
||||||
* Die Klasse wertet die englischsprachige Fehlermeldung aus dem Verarbeitungsversuch
|
* Die Klasse wertet die englischsprachige Fehlermeldung aus dem Verarbeitungsversuch
|
||||||
* musterbasiert aus und liefert eine für den Endbenutzer lesbare Beschreibung des
|
* musterbasiert aus und liefert eine für den Endbenutzer lesbare Beschreibung des
|
||||||
@@ -12,8 +12,10 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
|||||||
* Die Mustererkennung erfolgt ohne Berücksichtigung der Groß-/Kleinschreibung
|
* Die Mustererkennung erfolgt ohne Berücksichtigung der Groß-/Kleinschreibung
|
||||||
* und prüft die definierten Schlüsselbegriffe in festgelegter Reihenfolge,
|
* und prüft die definierten Schlüsselbegriffe in festgelegter Reihenfolge,
|
||||||
* damit spezifischere Muster vor allgemeineren greifen.
|
* damit spezifischere Muster vor allgemeineren greifen.
|
||||||
|
* <p>
|
||||||
|
* Die Klasse wird sowohl im Verarbeitungslauf-Tab als auch im Verlauf-Tab verwendet.
|
||||||
*/
|
*/
|
||||||
final class AiFailureMessageTranslator {
|
public final class AiFailureMessageTranslator {
|
||||||
|
|
||||||
private AiFailureMessageTranslator() {
|
private AiFailureMessageTranslator() {
|
||||||
}
|
}
|
||||||
@@ -28,7 +30,7 @@ final class AiFailureMessageTranslator {
|
|||||||
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
|
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
|
||||||
* @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol
|
* @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol
|
||||||
*/
|
*/
|
||||||
static String translate(String technicalMessage) {
|
public static String translate(String technicalMessage) {
|
||||||
if (technicalMessage == null || technicalMessage.isBlank()) {
|
if (technicalMessage == null || technicalMessage.isBlank()) {
|
||||||
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
|
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -7,7 +7,6 @@ import java.util.Map;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||||
import javafx.application.Platform;
|
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
|
|||||||
+3
@@ -76,6 +76,9 @@ public final class FileNameEditorPane {
|
|||||||
sectionTitle.setStyle("-fx-font-weight: bold;");
|
sectionTitle.setStyle("-fx-font-weight: bold;");
|
||||||
|
|
||||||
textField.setId("filename-editor-text-field");
|
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.setHgrow(textField, Priority.ALWAYS);
|
||||||
|
|
||||||
HBox inputRow = new HBox(4, textField);
|
HBox inputRow = new HBox(4, textField);
|
||||||
|
|||||||
+132
-14
@@ -21,9 +21,12 @@ 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.HistoricalDocumentContext;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
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.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.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.Alert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coordinates a single batch run (regular or targeted mini-run) triggered from the
|
* Coordinates a single batch run (regular or targeted mini-run) triggered from the
|
||||||
@@ -60,6 +63,9 @@ import javafx.application.Platform;
|
|||||||
* </ol>
|
* </ol>
|
||||||
*/
|
*/
|
||||||
public final class GuiBatchRunCoordinator {
|
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 Logger LOG = LogManager.getLogger(GuiBatchRunCoordinator.class);
|
||||||
private static final String WORKER_THREAD_NAME = "gui-batch-run";
|
private static final String WORKER_THREAD_NAME = "gui-batch-run";
|
||||||
@@ -115,6 +121,7 @@ public final class GuiBatchRunCoordinator {
|
|||||||
private final Consumer<Runnable> fxDispatcher;
|
private final Consumer<Runnable> fxDispatcher;
|
||||||
private final Listener listener;
|
private final Listener listener;
|
||||||
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
|
||||||
|
private final Optional<ConfigurationFileLockPort> configurationFileLockPort;
|
||||||
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
|
private final AtomicReference<Thread> activeWorker = new AtomicReference<>();
|
||||||
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
|
private final AtomicBoolean cancellationRequested = new AtomicBoolean();
|
||||||
|
|
||||||
@@ -176,6 +183,33 @@ public final class GuiBatchRunCoordinator {
|
|||||||
defaultThreadFactory(), defaultFxDispatcher(), listener, historicalDocumentContextPort);
|
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
|
* Creates the coordinator with custom hooks for the worker-thread factory and the
|
||||||
* UI-thread dispatcher.
|
* UI-thread dispatcher.
|
||||||
@@ -205,8 +239,8 @@ public final class GuiBatchRunCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the coordinator with all ports, custom thread factory, FX dispatcher and
|
* Creates the coordinator with all ports, custom thread factory, FX dispatcher,
|
||||||
* historical file name port.
|
* historical file name port, and an optional configuration file lock port.
|
||||||
* <p>
|
* <p>
|
||||||
* This is the canonical constructor. All other constructors delegate here.
|
* This is the canonical constructor. All other constructors delegate here.
|
||||||
*
|
*
|
||||||
@@ -221,6 +255,47 @@ public final class GuiBatchRunCoordinator {
|
|||||||
* @param listener GUI listener; must not be null
|
* @param listener GUI listener; must not be null
|
||||||
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
|
* @param historicalDocumentContextPort port for resolving the historical AI-proposed filename for
|
||||||
* skipped documents; must not be null
|
* 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,
|
public GuiBatchRunCoordinator(GuiBatchRunLauncher launcher,
|
||||||
GuiMiniRunLauncher miniRunLauncher,
|
GuiMiniRunLauncher miniRunLauncher,
|
||||||
@@ -229,14 +304,8 @@ public final class GuiBatchRunCoordinator {
|
|||||||
Consumer<Runnable> fxDispatcher,
|
Consumer<Runnable> fxDispatcher,
|
||||||
Listener listener,
|
Listener listener,
|
||||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
GuiHistoricalDocumentContextPort historicalDocumentContextPort) {
|
||||||
this.launcher = Objects.requireNonNull(launcher, "launcher must not be null");
|
this(launcher, miniRunLauncher, resetPort, threadFactory, fxDispatcher, listener,
|
||||||
this.miniRunLauncher = Objects.requireNonNull(miniRunLauncher, "miniRunLauncher must not be null");
|
historicalDocumentContextPort, Optional.empty());
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,7 +356,7 @@ public final class GuiBatchRunCoordinator {
|
|||||||
* @throws NullPointerException if {@code configFilePath} is {@code null}
|
* @throws NullPointerException if {@code configFilePath} is {@code null}
|
||||||
*/
|
*/
|
||||||
public boolean start(Path configFilePath) {
|
public boolean start(Path configFilePath) {
|
||||||
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||||
if (isRunning()) {
|
if (isRunning()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -313,7 +382,7 @@ public final class GuiBatchRunCoordinator {
|
|||||||
*/
|
*/
|
||||||
public boolean startMiniRun(Path configFilePath,
|
public boolean startMiniRun(Path configFilePath,
|
||||||
Set<DocumentFingerprint> fingerprintFilter) {
|
Set<DocumentFingerprint> fingerprintFilter) {
|
||||||
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||||
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
||||||
if (isRunning()) {
|
if (isRunning()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -345,7 +414,7 @@ public final class GuiBatchRunCoordinator {
|
|||||||
*/
|
*/
|
||||||
public boolean startReprocessing(Path configFilePath,
|
public boolean startReprocessing(Path configFilePath,
|
||||||
Set<DocumentFingerprint> fingerprintFilter) {
|
Set<DocumentFingerprint> fingerprintFilter) {
|
||||||
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||||
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
Objects.requireNonNull(fingerprintFilter, "fingerprintFilter must not be null");
|
||||||
if (isRunning()) {
|
if (isRunning()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -386,7 +455,7 @@ public final class GuiBatchRunCoordinator {
|
|||||||
* @throws NullPointerException if any argument is {@code null}
|
* @throws NullPointerException if any argument is {@code null}
|
||||||
*/
|
*/
|
||||||
public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
public boolean startReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
||||||
Objects.requireNonNull(configFilePath, "configFilePath must not be null");
|
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||||
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
|
Objects.requireNonNull(fingerprints, "fingerprints must not be null");
|
||||||
if (isRunning()) {
|
if (isRunning()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -437,6 +506,21 @@ public final class GuiBatchRunCoordinator {
|
|||||||
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
|
LOG.info("GUI-Verarbeitungslauf: Worker-Thread gestartet für Konfiguration {}.",
|
||||||
configFilePath);
|
configFilePath);
|
||||||
observerSummary.set(null);
|
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);
|
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
|
||||||
BatchRunCancellationToken token = cancellationRequested::get;
|
BatchRunCancellationToken token = cancellationRequested::get;
|
||||||
GuiBatchRunLaunchOutcome outcome;
|
GuiBatchRunLaunchOutcome outcome;
|
||||||
@@ -454,12 +538,30 @@ public final class GuiBatchRunCoordinator {
|
|||||||
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
}
|
}
|
||||||
finishRun(outcome);
|
finishRun(outcome);
|
||||||
|
} finally {
|
||||||
|
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void executeMiniRun(Path configFilePath, Set<DocumentFingerprint> fingerprintFilter) {
|
private void executeMiniRun(Path configFilePath, Set<DocumentFingerprint> fingerprintFilter) {
|
||||||
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
|
LOG.info("GUI-Mini-Verarbeitungslauf: Worker-Thread gestartet für {} Dokument(e), "
|
||||||
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
|
+ "Konfiguration {}.", fingerprintFilter.size(), configFilePath);
|
||||||
observerSummary.set(null);
|
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);
|
BatchRunProgressObserver observer = buildDispatchingObserver(configFilePath);
|
||||||
BatchRunCancellationToken token = cancellationRequested::get;
|
BatchRunCancellationToken token = cancellationRequested::get;
|
||||||
GuiBatchRunLaunchOutcome outcome;
|
GuiBatchRunLaunchOutcome outcome;
|
||||||
@@ -477,6 +579,9 @@ public final class GuiBatchRunCoordinator {
|
|||||||
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||||
}
|
}
|
||||||
finishRun(outcome);
|
finishRun(outcome);
|
||||||
|
} finally {
|
||||||
|
configurationFileLockPort.ifPresent(ConfigurationFileLockPort::releaseLock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
private void executeReset(Path configFilePath, Set<DocumentFingerprint> fingerprints) {
|
||||||
@@ -611,6 +716,19 @@ public final class GuiBatchRunCoordinator {
|
|||||||
historicalContext);
|
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() {
|
private static GuiHistoricalDocumentContextPort noOpHistoricalDocumentContextPort() {
|
||||||
return (configPath, fingerprint) -> Optional.empty();
|
return (configPath, fingerprint) -> Optional.empty();
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ public record GuiBatchRunLaunchOutcome(
|
|||||||
* Compact constructor normalising the failure message holder.
|
* Compact constructor normalising the failure message holder.
|
||||||
*/
|
*/
|
||||||
public GuiBatchRunLaunchOutcome {
|
public GuiBatchRunLaunchOutcome {
|
||||||
failureMessage = failureMessage == null ? Optional.empty() : failureMessage;
|
failureMessage = Objects.requireNonNullElse(failureMessage, Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+6
-6
@@ -88,16 +88,16 @@ public record GuiBatchRunResultRow(
|
|||||||
}
|
}
|
||||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
||||||
Objects.requireNonNull(status, "status must not be null");
|
Objects.requireNonNull(status, "status must not be null");
|
||||||
finalFileName = finalFileName == null ? Optional.empty() : finalFileName;
|
finalFileName = Objects.requireNonNullElse(finalFileName, Optional.empty());
|
||||||
correctedFileName = correctedFileName == null ? Optional.empty() : correctedFileName;
|
correctedFileName = Objects.requireNonNullElse(correctedFileName, Optional.empty());
|
||||||
resolvedDate = resolvedDate == null ? Optional.empty() : resolvedDate;
|
resolvedDate = Objects.requireNonNullElse(resolvedDate, Optional.empty());
|
||||||
aiReasoning = aiReasoning == null ? Optional.empty() : aiReasoning;
|
aiReasoning = Objects.requireNonNullElse(aiReasoning, Optional.empty());
|
||||||
aiFailureMessage = aiFailureMessage == null ? Optional.empty() : aiFailureMessage;
|
aiFailureMessage = Objects.requireNonNullElse(aiFailureMessage, Optional.empty());
|
||||||
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
Objects.requireNonNull(processingDuration, "processingDuration must not be null");
|
||||||
if (processingDuration.isNegative()) {
|
if (processingDuration.isNegative()) {
|
||||||
throw new IllegalArgumentException("processingDuration must not be negative");
|
throw new IllegalArgumentException("processingDuration must not be negative");
|
||||||
}
|
}
|
||||||
historicalContext = historicalContext == null ? Optional.empty() : historicalContext;
|
historicalContext = Objects.requireNonNullElse(historicalContext, Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+185
-71
@@ -41,8 +41,11 @@ import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSourceFile
|
|||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess;
|
import de.gecheckt.pdf.umbenenner.application.port.in.ManualFileRenameSuccess;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
|
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.in.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerStatus;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||||
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
import javafx.beans.property.ReadOnlyBooleanWrapper;
|
||||||
@@ -108,6 +111,11 @@ import javafx.scene.layout.VBox;
|
|||||||
* dafür, Hintergrundereignisse vor dem Callback auf den FX-Thread zu übertragen.
|
* dafür, Hintergrundereignisse vor dem Callback auf den FX-Thread zu übertragen.
|
||||||
*/
|
*/
|
||||||
public final class GuiBatchRunTab {
|
public final class GuiBatchRunTab {
|
||||||
|
private static final String COPY_FAILED_LOG = "Manuelle Dateikopie fehlgeschlagen: {}";
|
||||||
|
private static final String RENAME_FAILED_LOG = "Manuelle Dateiumbenennung fehlgeschlagen: {}";
|
||||||
|
private static final String DIRTY_STATE_MSG = "Dateiname-Editor: Ungespeicherte Änderungen";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static final Logger LOG = LogManager.getLogger(GuiBatchRunTab.class);
|
private static final Logger LOG = LogManager.getLogger(GuiBatchRunTab.class);
|
||||||
|
|
||||||
@@ -193,6 +201,9 @@ public final class GuiBatchRunTab {
|
|||||||
private final Button resetStatusButton = new Button("Status zurücksetzen");
|
private final Button resetStatusButton = new Button("Status zurücksetzen");
|
||||||
private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false);
|
private final ReadOnlyBooleanWrapper runningProperty = new ReadOnlyBooleanWrapper(false);
|
||||||
|
|
||||||
|
/** {@code true} while the automatic scheduler is in any non-{@code STOPPED} state. */
|
||||||
|
private boolean schedulerActive = false;
|
||||||
|
|
||||||
/** Dateiname-Editor-Komponente im Detailbereich. */
|
/** Dateiname-Editor-Komponente im Detailbereich. */
|
||||||
private final FileNameEditorPane fileNameEditor = new FileNameEditorPane();
|
private final FileNameEditorPane fileNameEditor = new FileNameEditorPane();
|
||||||
|
|
||||||
@@ -230,7 +241,8 @@ public final class GuiBatchRunTab {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt den Verarbeitungslauf-Tab mit allen Verarbeitungs-, Mini-Lauf- und
|
* Erstellt den Verarbeitungslauf-Tab mit allen Verarbeitungs-, Mini-Lauf- und
|
||||||
* Rücksetz-Fähigkeiten sowie dem Dateiname-Editor und der PDF-Vorschau.
|
* Rücksetz-Fähigkeiten sowie dem Dateiname-Editor, der PDF-Vorschau und einem
|
||||||
|
* optionalen OS-Lock auf die Konfigurationsdatei.
|
||||||
*
|
*
|
||||||
* @param launcherSupplier Supplier für den aktiven Batch-Lauf-Launcher;
|
* @param launcherSupplier Supplier für den aktiven Batch-Lauf-Launcher;
|
||||||
* darf nicht null sein
|
* darf nicht null sein
|
||||||
@@ -255,6 +267,9 @@ public final class GuiBatchRunTab {
|
|||||||
* darf leeres Optional zurückliefern
|
* darf leeres Optional zurückliefern
|
||||||
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner als
|
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner als
|
||||||
* Pfad-String; darf leeres Optional zurückliefern
|
* Pfad-String; darf leeres Optional zurückliefern
|
||||||
|
* @param configurationFileLockPort optionaler OS-Lock auf die Konfigurationsdatei;
|
||||||
|
* wird vor jedem Lauf erworben und danach freigegeben;
|
||||||
|
* {@code null} wird als leer behandelt
|
||||||
*/
|
*/
|
||||||
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
|
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
|
||||||
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
|
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
|
||||||
@@ -266,7 +281,8 @@ public final class GuiBatchRunTab {
|
|||||||
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
|
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
|
||||||
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
|
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
|
||||||
Supplier<Optional<Path>> sourceFolderSupplier,
|
Supplier<Optional<Path>> sourceFolderSupplier,
|
||||||
Supplier<Optional<String>> targetFolderSupplier) {
|
Supplier<Optional<String>> targetFolderSupplier,
|
||||||
|
Optional<ConfigurationFileLockPort> configurationFileLockPort) {
|
||||||
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
|
Objects.requireNonNull(launcherSupplier, "launcherSupplier must not be null");
|
||||||
Objects.requireNonNull(miniRunLauncherSupplier, "miniRunLauncherSupplier must not be null");
|
Objects.requireNonNull(miniRunLauncherSupplier, "miniRunLauncherSupplier must not be null");
|
||||||
Objects.requireNonNull(resetPortSupplier, "resetPortSupplier must not be null");
|
Objects.requireNonNull(resetPortSupplier, "resetPortSupplier must not be null");
|
||||||
@@ -285,6 +301,8 @@ public final class GuiBatchRunTab {
|
|||||||
this.targetFolderSupplier = Objects.requireNonNull(
|
this.targetFolderSupplier = Objects.requireNonNull(
|
||||||
targetFolderSupplier, "targetFolderSupplier must not be null");
|
targetFolderSupplier, "targetFolderSupplier must not be null");
|
||||||
|
|
||||||
|
Optional<ConfigurationFileLockPort> effectiveLockPort =
|
||||||
|
Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
|
||||||
this.coordinator = new GuiBatchRunCoordinator(
|
this.coordinator = new GuiBatchRunCoordinator(
|
||||||
(configPath, observer, token) ->
|
(configPath, observer, token) ->
|
||||||
launcherSupplier.get().launch(configPath, observer, token),
|
launcherSupplier.get().launch(configPath, observer, token),
|
||||||
@@ -293,7 +311,8 @@ public final class GuiBatchRunTab {
|
|||||||
(configPath, fingerprints) ->
|
(configPath, fingerprints) ->
|
||||||
resetPortSupplier.get().reset(configPath, fingerprints),
|
resetPortSupplier.get().reset(configPath, fingerprints),
|
||||||
new CoordinatorListener(),
|
new CoordinatorListener(),
|
||||||
historicalDocumentContextPortSupplier.get());
|
historicalDocumentContextPortSupplier.get(),
|
||||||
|
effectiveLockPort);
|
||||||
|
|
||||||
this.tab.setClosable(false);
|
this.tab.setClosable(false);
|
||||||
this.tab.setContent(buildContent());
|
this.tab.setContent(buildContent());
|
||||||
@@ -312,6 +331,51 @@ public final class GuiBatchRunTab {
|
|||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückwärtskompatible Variante ohne OS-Lock auf die Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Alle bestehenden Aufrufer, die vor der Lock-Port-Erweiterung erstellt wurden,
|
||||||
|
* nutzen diesen Konstruktor. Er delegiert an den kanonischen Konstruktor mit
|
||||||
|
* {@code configurationFileLockPort = Optional.empty()}.
|
||||||
|
*
|
||||||
|
* @param launcherSupplier Supplier für den aktiven Batch-Lauf-Launcher;
|
||||||
|
* darf nicht null sein
|
||||||
|
* @param miniRunLauncherSupplier Supplier für den Mini-Lauf-Launcher;
|
||||||
|
* darf nicht null sein
|
||||||
|
* @param resetPortSupplier Supplier für den Rücksetz-Port;
|
||||||
|
* darf nicht null sein
|
||||||
|
* @param configPathSupplier Supplier für den letzten gespeicherten
|
||||||
|
* Konfigurationspfad; darf null zurückliefern
|
||||||
|
* @param savedConfigurationReadyCheck Prüfung vor jedem Startversuch; darf nicht
|
||||||
|
* null sein
|
||||||
|
* @param onRunStateChanged Callback wenn das Lauf-Flag kippt; darf nicht
|
||||||
|
* null sein
|
||||||
|
* @param manualFileRenamePortSupplier Supplier für den manuellen Umbenennungs-Port;
|
||||||
|
* darf nicht null sein
|
||||||
|
* @param manualFileCopyPortSupplier Supplier für den manuellen Kopier-Port;
|
||||||
|
* darf nicht null sein
|
||||||
|
* @param historicalDocumentContextPortSupplier Supplier für den historischen Kontext-Port;
|
||||||
|
* darf nicht null sein
|
||||||
|
* @param sourceFolderSupplier Supplier für den konfigurierten Quellordner
|
||||||
|
* @param targetFolderSupplier Supplier für den konfigurierten Zielordner
|
||||||
|
*/
|
||||||
|
public GuiBatchRunTab(Supplier<GuiBatchRunLauncher> launcherSupplier,
|
||||||
|
Supplier<GuiMiniRunLauncher> miniRunLauncherSupplier,
|
||||||
|
Supplier<GuiResetDocumentStatusPort> resetPortSupplier,
|
||||||
|
Supplier<Path> configPathSupplier,
|
||||||
|
BooleanSupplier savedConfigurationReadyCheck,
|
||||||
|
Runnable onRunStateChanged,
|
||||||
|
Supplier<GuiManualFileRenamePort> manualFileRenamePortSupplier,
|
||||||
|
Supplier<GuiManualFileCopyPort> manualFileCopyPortSupplier,
|
||||||
|
Supplier<GuiHistoricalDocumentContextPort> historicalDocumentContextPortSupplier,
|
||||||
|
Supplier<Optional<Path>> sourceFolderSupplier,
|
||||||
|
Supplier<Optional<String>> targetFolderSupplier) {
|
||||||
|
this(launcherSupplier, miniRunLauncherSupplier, resetPortSupplier, configPathSupplier,
|
||||||
|
savedConfigurationReadyCheck, onRunStateChanged, manualFileRenamePortSupplier,
|
||||||
|
manualFileCopyPortSupplier, historicalDocumentContextPortSupplier,
|
||||||
|
sourceFolderSupplier, targetFolderSupplier, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rückwärtskompatible Variante für Aufrufer ohne Mini-Lauf- oder Rücksetz-Fähigkeiten.
|
* Rückwärtskompatible Variante für Aufrufer ohne Mini-Lauf- oder Rücksetz-Fähigkeiten.
|
||||||
*
|
*
|
||||||
@@ -437,6 +501,25 @@ public final class GuiBatchRunTab {
|
|||||||
return askDiscardFilenameChanges();
|
return askDiscardFilenameChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert den Tab-Zustand anhand des aktuellen Scheduler-Status.
|
||||||
|
* <p>
|
||||||
|
* Deaktiviert den Starten-Button und setzt einen erklärenden Tooltip, solange
|
||||||
|
* der Scheduler aktiv ist. Wenn der Scheduler gestoppt ist, wird der normale
|
||||||
|
* Button-Zustand wiederhergestellt (Starten erlaubt sofern kein Lauf läuft).
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param status aktueller Scheduler-Status; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public void updateSchedulerState(SchedulerStatus status) {
|
||||||
|
schedulerActive = status.state().isActive();
|
||||||
|
startButton.setDisable(runningProperty.get() || schedulerActive);
|
||||||
|
startButton.setTooltip(new Tooltip(schedulerActive
|
||||||
|
? "Manuelle Läufe sind während aktivem Scheduler nicht möglich."
|
||||||
|
: GuiTooltipTexts.BATCHRUN_STARTEN));
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Paket-private Accessor für Tests
|
// Paket-private Accessor für Tests
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -527,19 +610,22 @@ public final class GuiBatchRunTab {
|
|||||||
// Selektions-Aktions-Buttons unterhalb der Tabelle (linke Spalte)
|
// Selektions-Aktions-Buttons unterhalb der Tabelle (linke Spalte)
|
||||||
reprocessButton.setId("batch-run-reprocess");
|
reprocessButton.setId("batch-run-reprocess");
|
||||||
reprocessButton.setOnAction(event -> handleReprocessSelected());
|
reprocessButton.setOnAction(event -> handleReprocessSelected());
|
||||||
|
reprocessButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ERNEUT_VERARBEITEN));
|
||||||
|
|
||||||
resetStatusButton.setId("batch-run-reset-status");
|
resetStatusButton.setId("batch-run-reset-status");
|
||||||
resetStatusButton.setOnAction(event -> handleResetSelected());
|
resetStatusButton.setOnAction(event -> handleResetSelected());
|
||||||
|
resetStatusButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STATUS_ZURUECKSETZEN));
|
||||||
|
|
||||||
HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton);
|
HBox selectionButtonBar = new HBox(SECONDARY_SPACING, reprocessButton, resetStatusButton);
|
||||||
selectionButtonBar.setAlignment(Pos.CENTER_LEFT);
|
selectionButtonBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, SECONDARY_SPACING / 2, 0));
|
selectionButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2.0, 0, SECONDARY_SPACING / 2.0, 0));
|
||||||
|
|
||||||
// Meldungsbereich unterhalb der Selektions-Buttons (linke Spalte)
|
// Meldungsbereich unterhalb der Selektions-Buttons (linke Spalte)
|
||||||
messageArea.setId("batch-run-message-area");
|
messageArea.setId("batch-run-message-area");
|
||||||
messageArea.setEditable(false);
|
messageArea.setEditable(false);
|
||||||
messageArea.setWrapText(true);
|
messageArea.setWrapText(true);
|
||||||
messageArea.setPrefRowCount(3);
|
messageArea.setPrefRowCount(3);
|
||||||
|
messageArea.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MESSAGE_AREA));
|
||||||
// Hinweisbereich erst einblenden wenn eine Meldung vorliegt
|
// Hinweisbereich erst einblenden wenn eine Meldung vorliegt
|
||||||
messageArea.setVisible(false);
|
messageArea.setVisible(false);
|
||||||
messageArea.setManaged(false);
|
messageArea.setManaged(false);
|
||||||
@@ -600,12 +686,14 @@ public final class GuiBatchRunTab {
|
|||||||
|
|
||||||
masterCheckBox.setId("batch-run-master-checkbox");
|
masterCheckBox.setId("batch-run-master-checkbox");
|
||||||
masterCheckBox.setOnAction(e -> handleMasterCheckBoxAction());
|
masterCheckBox.setOnAction(e -> handleMasterCheckBoxAction());
|
||||||
|
masterCheckBox.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_MASTER_CHECKBOX));
|
||||||
checkboxCol.setGraphic(masterCheckBox);
|
checkboxCol.setGraphic(masterCheckBox);
|
||||||
|
|
||||||
checkboxCol.setCellFactory(col -> new CheckBoxCell());
|
checkboxCol.setCellFactory(col -> new CheckBoxCell());
|
||||||
checkboxCol.setEditable(true);
|
checkboxCol.setEditable(true);
|
||||||
|
|
||||||
TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>("Status");
|
TableColumn<GuiBatchRunResultRow, String> iconCol = new TableColumn<>();
|
||||||
|
iconCol.setGraphic(columnHeader("Status", GuiTooltipTexts.BATCHRUN_COL_STATUS));
|
||||||
iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon()));
|
iconCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().statusIcon()));
|
||||||
iconCol.setPrefWidth(64);
|
iconCol.setPrefWidth(64);
|
||||||
iconCol.setCellFactory(col -> new TableCell<GuiBatchRunResultRow, String>() {
|
iconCol.setCellFactory(col -> new TableCell<GuiBatchRunResultRow, String>() {
|
||||||
@@ -636,11 +724,13 @@ public final class GuiBatchRunTab {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
TableColumn<GuiBatchRunResultRow, String> nameCol = new TableColumn<>("Originaldateiname");
|
TableColumn<GuiBatchRunResultRow, String> nameCol = new TableColumn<>();
|
||||||
|
nameCol.setGraphic(columnHeader("Originaldateiname", GuiTooltipTexts.BATCHRUN_COL_ORIGINALDATEINAME));
|
||||||
nameCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().originalFileName()));
|
nameCol.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().originalFileName()));
|
||||||
nameCol.setPrefWidth(280);
|
nameCol.setPrefWidth(280);
|
||||||
|
|
||||||
TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>("Neuer Dateiname");
|
TableColumn<GuiBatchRunResultRow, String> newNameCol = new TableColumn<>();
|
||||||
|
newNameCol.setGraphic(columnHeader("Neuer Dateiname", GuiTooltipTexts.BATCHRUN_COL_NEUER_DATEINAME));
|
||||||
newNameCol.setCellValueFactory(data -> {
|
newNameCol.setCellValueFactory(data -> {
|
||||||
GuiBatchRunResultRow row = data.getValue();
|
GuiBatchRunResultRow row = data.getValue();
|
||||||
if (row.resetPending()) {
|
if (row.resetPending()) {
|
||||||
@@ -650,14 +740,16 @@ public final class GuiBatchRunTab {
|
|||||||
});
|
});
|
||||||
newNameCol.setPrefWidth(280);
|
newNameCol.setPrefWidth(280);
|
||||||
|
|
||||||
TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>("Datum");
|
TableColumn<GuiBatchRunResultRow, String> dateCol = new TableColumn<>();
|
||||||
|
dateCol.setGraphic(columnHeader("Datum", GuiTooltipTexts.BATCHRUN_COL_DATUM));
|
||||||
dateCol.setCellValueFactory(data -> new SimpleStringProperty(
|
dateCol.setCellValueFactory(data -> new SimpleStringProperty(
|
||||||
data.getValue().resolvedDate()
|
data.getValue().resolvedDate()
|
||||||
.map(DateTimeFormatter.ISO_LOCAL_DATE::format)
|
.map(DateTimeFormatter.ISO_LOCAL_DATE::format)
|
||||||
.orElse(EMPTY_CELL_TEXT)));
|
.orElse(EMPTY_CELL_TEXT)));
|
||||||
dateCol.setPrefWidth(100);
|
dateCol.setPrefWidth(100);
|
||||||
|
|
||||||
TableColumn<GuiBatchRunResultRow, String> durationCol = new TableColumn<>("Dauer");
|
TableColumn<GuiBatchRunResultRow, String> durationCol = new TableColumn<>();
|
||||||
|
durationCol.setGraphic(columnHeader("Dauer", GuiTooltipTexts.BATCHRUN_COL_DAUER));
|
||||||
durationCol.setCellValueFactory(data -> new SimpleStringProperty(
|
durationCol.setCellValueFactory(data -> new SimpleStringProperty(
|
||||||
formatDuration(data.getValue().processingDuration())));
|
formatDuration(data.getValue().processingDuration())));
|
||||||
durationCol.setPrefWidth(80);
|
durationCol.setPrefWidth(80);
|
||||||
@@ -733,7 +825,7 @@ public final class GuiBatchRunTab {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileNameEditor.discardChanges();
|
fileNameEditor.discardChanges();
|
||||||
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung – Benutzer hat verworfen");
|
LOG.debug(DIRTY_STATE_MSG);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neue Zeile laden
|
// Neue Zeile laden
|
||||||
@@ -870,16 +962,35 @@ public final class GuiBatchRunTab {
|
|||||||
*/
|
*/
|
||||||
private void handleCopyResult(ManualFileCopyResult result, GuiBatchRunResultRow row) {
|
private void handleCopyResult(ManualFileCopyResult result, GuiBatchRunResultRow row) {
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case ManualFileCopySuccess success -> {
|
case ManualFileCopySuccess success -> applyCopySuccess(success, row);
|
||||||
|
case ManualFileCopyNoOpIdenticalTarget noOp -> applyCopyNoOpIdentical(noOp, row);
|
||||||
|
case ManualFileCopyDocumentNotFound notFound -> {
|
||||||
|
LOG.warn(COPY_FAILED_LOG, notFound.reason());
|
||||||
|
showMessage("Fehler: Dokument nicht gefunden – " + notFound.reason());
|
||||||
|
}
|
||||||
|
case ManualFileCopyInvalidState invalidState -> {
|
||||||
|
LOG.warn(COPY_FAILED_LOG, invalidState.reason());
|
||||||
|
showMessage("Fehler: Ungültiger Dokumentstatus – " + invalidState.reason());
|
||||||
|
}
|
||||||
|
case ManualFileCopyFileSystemFailure fsFail -> {
|
||||||
|
LOG.warn(COPY_FAILED_LOG, fsFail.message());
|
||||||
|
showMessage("Dateisystemfehler: " + fsFail.message());
|
||||||
|
}
|
||||||
|
case ManualFileCopyPersistenceFailure persistFail -> {
|
||||||
|
LOG.warn(COPY_FAILED_LOG, persistFail.message());
|
||||||
|
showMessage("Persistenzfehler (Zielkopie wurde zurückgerollt): " + persistFail.message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyCopySuccess(ManualFileCopySuccess success, GuiBatchRunResultRow row) {
|
||||||
LOG.info("Manuelle Dateikopie erfolgreich: {} → {} (Suffix: {})",
|
LOG.info("Manuelle Dateikopie erfolgreich: {} → {} (Suffix: {})",
|
||||||
row.originalFileName(), success.appliedFileName(),
|
row.originalFileName(), success.appliedFileName(), success.conflictSuffixApplied());
|
||||||
success.conflictSuffixApplied());
|
|
||||||
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, success.appliedFileName());
|
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, success.appliedFileName());
|
||||||
currentlySelectedRow = updatedRow;
|
currentlySelectedRow = updatedRow;
|
||||||
fileNameEditor.clearDirtyState();
|
fileNameEditor.clearDirtyState();
|
||||||
upsertResultRowByFingerprint(updatedRow);
|
upsertResultRowByFingerprint(updatedRow);
|
||||||
String targetFolder = targetFolderSupplier.get().orElse("");
|
fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
|
||||||
fileNameEditor.loadSelection(updatedRow, targetFolder);
|
|
||||||
String msg = "Datei kopiert und gespeichert: " + success.appliedFileName();
|
String msg = "Datei kopiert und gespeichert: " + success.appliedFileName();
|
||||||
if (success.conflictSuffixApplied()) {
|
if (success.conflictSuffixApplied()) {
|
||||||
msg += " (Suffix wegen Namenskonflikt angehängt)";
|
msg += " (Suffix wegen Namenskonflikt angehängt)";
|
||||||
@@ -887,37 +998,18 @@ public final class GuiBatchRunTab {
|
|||||||
showMessage(msg);
|
showMessage(msg);
|
||||||
refreshAggregateCountersFromItems();
|
refreshAggregateCountersFromItems();
|
||||||
}
|
}
|
||||||
case ManualFileCopyNoOpIdenticalTarget noOp -> {
|
|
||||||
|
private void applyCopyNoOpIdentical(ManualFileCopyNoOpIdenticalTarget noOp, GuiBatchRunResultRow row) {
|
||||||
LOG.info("Manuelle Dateikopie: identische Zieldatei {} bereits vorhanden – kein Schreibvorgang.",
|
LOG.info("Manuelle Dateikopie: identische Zieldatei {} bereits vorhanden – kein Schreibvorgang.",
|
||||||
noOp.existingFileName());
|
noOp.existingFileName());
|
||||||
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, noOp.existingFileName());
|
GuiBatchRunResultRow updatedRow = buildSuccessRowAfterCopy(row, noOp.existingFileName());
|
||||||
currentlySelectedRow = updatedRow;
|
currentlySelectedRow = updatedRow;
|
||||||
fileNameEditor.clearDirtyState();
|
fileNameEditor.clearDirtyState();
|
||||||
upsertResultRowByFingerprint(updatedRow);
|
upsertResultRowByFingerprint(updatedRow);
|
||||||
String targetFolder = targetFolderSupplier.get().orElse("");
|
fileNameEditor.loadSelection(updatedRow, targetFolderSupplier.get().orElse(""));
|
||||||
fileNameEditor.loadSelection(updatedRow, targetFolder);
|
|
||||||
showMessage("Identische Datei bereits vorhanden – Status auf SUCCESS gesetzt");
|
showMessage("Identische Datei bereits vorhanden – Status auf SUCCESS gesetzt");
|
||||||
refreshAggregateCountersFromItems();
|
refreshAggregateCountersFromItems();
|
||||||
}
|
}
|
||||||
case ManualFileCopyDocumentNotFound notFound -> {
|
|
||||||
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", notFound.reason());
|
|
||||||
showMessage("Fehler: Dokument nicht gefunden – " + notFound.reason());
|
|
||||||
}
|
|
||||||
case ManualFileCopyInvalidState invalidState -> {
|
|
||||||
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", invalidState.reason());
|
|
||||||
showMessage("Fehler: Ungültiger Dokumentstatus – " + invalidState.reason());
|
|
||||||
}
|
|
||||||
case ManualFileCopyFileSystemFailure fsFail -> {
|
|
||||||
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", fsFail.message());
|
|
||||||
showMessage("Dateisystemfehler: " + fsFail.message());
|
|
||||||
}
|
|
||||||
case ManualFileCopyPersistenceFailure persistFail -> {
|
|
||||||
LOG.warn("Manuelle Dateikopie fehlgeschlagen: {}", persistFail.message());
|
|
||||||
showMessage("Persistenzfehler (Zielkopie wurde zurückgerollt): "
|
|
||||||
+ persistFail.message());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Baut eine neue Zeilen-Sicht für ein Dokument, das per manueller Dateikopie auf
|
* Baut eine neue Zeilen-Sicht für ein Dokument, das per manueller Dateikopie auf
|
||||||
@@ -1018,24 +1110,24 @@ public final class GuiBatchRunTab {
|
|||||||
noOp.existingFileName());
|
noOp.existingFileName());
|
||||||
}
|
}
|
||||||
case ManualFileRenameDocumentNotFound notFound -> {
|
case ManualFileRenameDocumentNotFound notFound -> {
|
||||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", notFound.reason());
|
LOG.warn(RENAME_FAILED_LOG, notFound.reason());
|
||||||
showMessage("Fehler: Dokument nicht gefunden – " + notFound.reason());
|
showMessage("Fehler: Dokument nicht gefunden – " + notFound.reason());
|
||||||
}
|
}
|
||||||
case ManualFileRenameInvalidState invalidState -> {
|
case ManualFileRenameInvalidState invalidState -> {
|
||||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", invalidState.reason());
|
LOG.warn(RENAME_FAILED_LOG, invalidState.reason());
|
||||||
showMessage("Fehler: Ungültiger Dokumentstatus – " + invalidState.reason());
|
showMessage("Fehler: Ungültiger Dokumentstatus – " + invalidState.reason());
|
||||||
}
|
}
|
||||||
case ManualFileRenameSourceFileMissing sourceMissing -> {
|
case ManualFileRenameSourceFileMissing sourceMissing -> {
|
||||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}",
|
LOG.warn(RENAME_FAILED_LOG,
|
||||||
sourceMissing.expectedFileName());
|
sourceMissing.expectedFileName());
|
||||||
showMessage("Zieldatei nicht gefunden – Umbenennung nicht möglich");
|
showMessage("Zieldatei nicht gefunden – Umbenennung nicht möglich");
|
||||||
}
|
}
|
||||||
case ManualFileRenameFileSystemFailure fsFail -> {
|
case ManualFileRenameFileSystemFailure fsFail -> {
|
||||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", fsFail.message());
|
LOG.warn(RENAME_FAILED_LOG, fsFail.message());
|
||||||
showMessage("Dateisystemfehler: " + fsFail.message());
|
showMessage("Dateisystemfehler: " + fsFail.message());
|
||||||
}
|
}
|
||||||
case ManualFileRenamePersistenceFailure persistFail -> {
|
case ManualFileRenamePersistenceFailure persistFail -> {
|
||||||
LOG.warn("Manuelle Dateiumbenennung fehlgeschlagen: {}", persistFail.message());
|
LOG.warn(RENAME_FAILED_LOG, persistFail.message());
|
||||||
showMessage("Persistenzfehler (Dateisystem wurde zurückgerollt): "
|
showMessage("Persistenzfehler (Dateisystem wurde zurückgerollt): "
|
||||||
+ persistFail.message());
|
+ persistFail.message());
|
||||||
}
|
}
|
||||||
@@ -1145,14 +1237,16 @@ public final class GuiBatchRunTab {
|
|||||||
// Lauf-Steuerungs-Buttons
|
// Lauf-Steuerungs-Buttons
|
||||||
startButton.setId("batch-run-start");
|
startButton.setId("batch-run-start");
|
||||||
startButton.setOnAction(event -> handleStart());
|
startButton.setOnAction(event -> handleStart());
|
||||||
|
startButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_STARTEN));
|
||||||
|
|
||||||
cancelButton.setId("batch-run-cancel");
|
cancelButton.setId("batch-run-cancel");
|
||||||
cancelButton.setOnAction(event -> requestCancellation());
|
cancelButton.setOnAction(event -> requestCancellation());
|
||||||
cancelButton.setDisable(true);
|
cancelButton.setDisable(true);
|
||||||
|
cancelButton.setTooltip(new Tooltip(GuiTooltipTexts.BATCHRUN_ABBRECHEN));
|
||||||
|
|
||||||
HBox runButtonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
|
HBox runButtonBar = new HBox(SECONDARY_SPACING, startButton, cancelButton);
|
||||||
runButtonBar.setAlignment(Pos.CENTER_LEFT);
|
runButtonBar.setAlignment(Pos.CENTER_LEFT);
|
||||||
runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2, 0, 0, 0));
|
runButtonBar.setPadding(new Insets(SECONDARY_SPACING / 2.0, 0, 0, 0));
|
||||||
|
|
||||||
return runButtonBar;
|
return runButtonBar;
|
||||||
}
|
}
|
||||||
@@ -1174,7 +1268,7 @@ public final class GuiBatchRunTab {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileNameEditor.discardChanges();
|
fileNameEditor.discardChanges();
|
||||||
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung – Benutzer hat verworfen");
|
LOG.debug(DIRTY_STATE_MSG);
|
||||||
}
|
}
|
||||||
if (!savedConfigurationReadyCheck.getAsBoolean()) {
|
if (!savedConfigurationReadyCheck.getAsBoolean()) {
|
||||||
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||||
@@ -1228,7 +1322,7 @@ public final class GuiBatchRunTab {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileNameEditor.discardChanges();
|
fileNameEditor.discardChanges();
|
||||||
LOG.debug("Dateiname-Editor: Ungespeicherte Änderung – Benutzer hat verworfen");
|
LOG.debug(DIRTY_STATE_MSG);
|
||||||
}
|
}
|
||||||
if (!savedConfigurationReadyCheck.getAsBoolean()) {
|
if (!savedConfigurationReadyCheck.getAsBoolean()) {
|
||||||
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
showMessage(NO_SAVED_CONFIGURATION_HINT);
|
||||||
@@ -1403,7 +1497,7 @@ public final class GuiBatchRunTab {
|
|||||||
|
|
||||||
private void updateButtonStates() {
|
private void updateButtonStates() {
|
||||||
boolean running = runningProperty.get();
|
boolean running = runningProperty.get();
|
||||||
startButton.setDisable(running);
|
startButton.setDisable(running || schedulerActive);
|
||||||
if (!running) {
|
if (!running) {
|
||||||
cancelButton.setDisable(true);
|
cancelButton.setDisable(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -1439,6 +1533,21 @@ public final class GuiBatchRunTab {
|
|||||||
|
|
||||||
// statusColor() wurde zugunsten von ProcessingStatusPresentation.cssColorFor() entfernt.
|
// statusColor() wurde zugunsten von ProcessingStatusPresentation.cssColorFor() entfernt.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein Label für den Spaltenkopf einer TableColumn mit Tooltip.
|
||||||
|
* Wird anstelle von {@code column.setText()} verwendet, da TableColumn
|
||||||
|
* kein direktes {@code setTooltip()} unterstützt.
|
||||||
|
*
|
||||||
|
* @param title sichtbarer Spaltentext; darf nicht leer sein
|
||||||
|
* @param tooltip Tooltip-Text; darf nicht leer sein
|
||||||
|
* @return ein Label mit gesetztem Tooltip
|
||||||
|
*/
|
||||||
|
private static Label columnHeader(String title, String tooltip) {
|
||||||
|
Label label = new Label(title);
|
||||||
|
label.setTooltip(new Tooltip(tooltip));
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
private static String formatDuration(Duration duration) {
|
private static String formatDuration(Duration duration) {
|
||||||
double seconds = duration.toMillis() / 1000.0;
|
double seconds = duration.toMillis() / 1000.0;
|
||||||
if (seconds < 10) {
|
if (seconds < 10) {
|
||||||
@@ -1458,35 +1567,12 @@ public final class GuiBatchRunTab {
|
|||||||
return builder.toString();
|
return builder.toString();
|
||||||
}
|
}
|
||||||
if (row.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED) {
|
if (row.status() == DocumentCompletionStatus.SKIPPED_ALREADY_PROCESSED) {
|
||||||
builder.append('\n');
|
return appendSkippedAlreadyProcessed(builder, row);
|
||||||
row.historicalContext().ifPresentOrElse(ctx -> {
|
|
||||||
ctx.lastSuccessInstant().ifPresentOrElse(
|
|
||||||
instant -> builder.append("Bereits erfolgreich verarbeitet am ")
|
|
||||||
.append(DETAIL_DATE_FORMAT.format(
|
|
||||||
instant.atZone(ZoneId.systemDefault())))
|
|
||||||
.append('.'),
|
|
||||||
() -> builder.append("Bereits erfolgreich verarbeitet."));
|
|
||||||
ctx.lastTargetFileName().ifPresent(name ->
|
|
||||||
builder.append('\n').append("Zieldatei: ").append(name).append('.'));
|
|
||||||
}, () -> builder.append("Bereits erfolgreich verarbeitet."));
|
|
||||||
return builder.toString();
|
|
||||||
}
|
}
|
||||||
if (row.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE) {
|
if (row.status() == DocumentCompletionStatus.SKIPPED_FINAL_FAILURE) {
|
||||||
builder.append('\n');
|
return appendSkippedFinalFailure(builder, row);
|
||||||
row.historicalContext().ifPresentOrElse(ctx ->
|
|
||||||
ctx.lastFailureInstant().ifPresentOrElse(
|
|
||||||
instant -> builder.append("Endg\u00fcltig fehlgeschlagen am ")
|
|
||||||
.append(DETAIL_DATE_FORMAT.format(
|
|
||||||
instant.atZone(ZoneId.systemDefault())))
|
|
||||||
.append(". Erneute Verarbeitung nur nach Reset m\u00f6glich."),
|
|
||||||
() -> builder.append(
|
|
||||||
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich.")),
|
|
||||||
() -> builder.append(
|
|
||||||
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
|
|
||||||
return builder.toString();
|
|
||||||
}
|
}
|
||||||
if (row.status() == DocumentCompletionStatus.FAILED_PERMANENT) {
|
if (row.status() == DocumentCompletionStatus.FAILED_PERMANENT) {
|
||||||
// Erweiterter Erkl\u00e4rungstext gem\u00e4\u00df Spezifikation #51 \u2013 dauerhaft fehlgeschlagen
|
|
||||||
builder.append('\n').append(ProcessingStatusPresentation.DETAIL_TEXT_FAILED_PERMANENT);
|
builder.append('\n').append(ProcessingStatusPresentation.DETAIL_TEXT_FAILED_PERMANENT);
|
||||||
row.aiFailureMessage().ifPresent(msg ->
|
row.aiFailureMessage().ifPresent(msg ->
|
||||||
builder.append("\n\nFehlerdetail: ")
|
builder.append("\n\nFehlerdetail: ")
|
||||||
@@ -1507,6 +1593,34 @@ public final class GuiBatchRunTab {
|
|||||||
return builder.toString();
|
return builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String appendSkippedAlreadyProcessed(StringBuilder builder, GuiBatchRunResultRow row) {
|
||||||
|
builder.append('\n');
|
||||||
|
row.historicalContext().ifPresentOrElse(ctx -> {
|
||||||
|
ctx.lastSuccessInstant().ifPresentOrElse(
|
||||||
|
instant -> builder.append("Bereits erfolgreich verarbeitet am ")
|
||||||
|
.append(DETAIL_DATE_FORMAT.format(instant.atZone(ZoneId.systemDefault())))
|
||||||
|
.append('.'),
|
||||||
|
() -> builder.append("Bereits erfolgreich verarbeitet."));
|
||||||
|
ctx.lastTargetFileName().ifPresent(name ->
|
||||||
|
builder.append('\n').append("Zieldatei: ").append(name).append('.'));
|
||||||
|
}, () -> builder.append("Bereits erfolgreich verarbeitet."));
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String appendSkippedFinalFailure(StringBuilder builder, GuiBatchRunResultRow row) {
|
||||||
|
builder.append('\n');
|
||||||
|
row.historicalContext().ifPresentOrElse(ctx ->
|
||||||
|
ctx.lastFailureInstant().ifPresentOrElse(
|
||||||
|
instant -> builder.append("Endg\u00fcltig fehlgeschlagen am ")
|
||||||
|
.append(DETAIL_DATE_FORMAT.format(instant.atZone(ZoneId.systemDefault())))
|
||||||
|
.append(". Erneute Verarbeitung nur nach Reset m\u00f6glich."),
|
||||||
|
() -> builder.append(
|
||||||
|
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich.")),
|
||||||
|
() -> builder.append(
|
||||||
|
"Endg\u00fcltig fehlgeschlagen. Erneute Verarbeitung nur nach Reset m\u00f6glich."));
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private static GuiBatchRunLaunchOutcome rejectingMiniLaunch(
|
private static GuiBatchRunLaunchOutcome rejectingMiniLaunch(
|
||||||
Path p, Set<DocumentFingerprint> f,
|
Path p, Set<DocumentFingerprint> f,
|
||||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver o,
|
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver o,
|
||||||
|
|||||||
+340
-21
@@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
@@ -19,13 +20,21 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
|||||||
|
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.embed.swing.SwingFXUtils;
|
import javafx.embed.swing.SwingFXUtils;
|
||||||
|
import javafx.geometry.Bounds;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.geometry.Pos;
|
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.Button;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.control.ProgressIndicator;
|
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.Image;
|
||||||
import javafx.scene.image.ImageView;
|
import javafx.scene.image.ImageView;
|
||||||
|
import javafx.scene.input.MouseEvent;
|
||||||
|
import javafx.scene.input.ScrollEvent;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
import javafx.scene.layout.Region;
|
||||||
@@ -36,10 +45,21 @@ import javafx.scene.layout.VBox;
|
|||||||
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
|
* Detailbereich-Komponente zur asynchronen Anzeige von Seiten einer Quelldatei.
|
||||||
*
|
*
|
||||||
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
|
* <p>Die Komponente rendert PDF-Seiten direkt mit Apache PDFBox und zeigt das Ergebnis
|
||||||
* in einer {@link ImageView} an. Die Anzeige ist vollständig eingepasst (fit-to-view):
|
* in einer {@link ImageView} an. Im Fit-to-View-Modus (Standardzustand) sind
|
||||||
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} sind an die Größe des
|
* {@code fitWidth} und {@code fitHeight} der {@link ImageView} an die Größe des
|
||||||
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
|
* umgebenden {@link StackPane} gebunden, {@code preserveRatio=true} erhält das
|
||||||
* Seitenverhältnis. Es entstehen weder Scrollbalken noch Zoom-Artefakte.
|
* 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
|
* <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
|
* dedizierten Worker-Thread. UI-Updates laufen ausschließlich über den JavaFX
|
||||||
@@ -77,6 +97,18 @@ public final class PdfPreviewPane {
|
|||||||
/** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
|
/** Render-Auflösung in DPI. 120 DPI ist ein guter Kompromiss aus Qualität und Geschwindigkeit. */
|
||||||
private static final float RENDER_DPI = 120f;
|
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 VBox root = new VBox(4);
|
||||||
private final StackPane viewStack = new StackPane();
|
private final StackPane viewStack = new StackPane();
|
||||||
private final ImageView imageView = new ImageView();
|
private final ImageView imageView = new ImageView();
|
||||||
@@ -86,6 +118,35 @@ public final class PdfPreviewPane {
|
|||||||
private final Button prevButton = new Button("◀ Vorherige");
|
private final Button prevButton = new Button("◀ Vorherige");
|
||||||
private final Button nextButton = new Button("Nächste ▶");
|
private final Button nextButton = new Button("Nächste ▶");
|
||||||
private final Label sectionTitle = new Label("PDF-Vorschau");
|
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
|
* Sequenznummer der aktuell angeforderten Vorschau. Jede neue Anforderung
|
||||||
@@ -110,18 +171,18 @@ public final class PdfPreviewPane {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread.
|
* Aktuell geöffnetes PDF-Dokument. Zugriff ausschließlich vom Worker-Thread.
|
||||||
* {@code null} wenn kein Dokument geöffnet ist.
|
* Leerer Referenzwert wenn kein Dokument geöffnet ist.
|
||||||
*/
|
*/
|
||||||
private volatile PDDocument currentDocument = null;
|
private final AtomicReference<PDDocument> currentDocument = new AtomicReference<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread.
|
* Renderer für das aktuell geöffnete Dokument. Zugriff ausschließlich vom Worker-Thread.
|
||||||
* {@code null} wenn kein Dokument geöffnet ist.
|
* Leerer Referenzwert wenn kein Dokument geöffnet ist.
|
||||||
*/
|
*/
|
||||||
private volatile PDFRenderer currentRenderer = null;
|
private final AtomicReference<PDFRenderer> currentRenderer = new AtomicReference<>();
|
||||||
|
|
||||||
/** Aktuell geladene Quelldatei; null wenn keine Selektion vorliegt. */
|
/** Aktuell geladene Quelldatei; leerer Referenzwert wenn keine Selektion vorliegt. */
|
||||||
private volatile Path currentSourceFile = null;
|
private final AtomicReference<Path> currentSourceFile = new AtomicReference<>();
|
||||||
|
|
||||||
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
|
/** Aktuell angezeigte Seite (1-basiert; 0 wenn keine Datei geladen). */
|
||||||
private volatile int currentPage = 0;
|
private volatile int currentPage = 0;
|
||||||
@@ -162,13 +223,48 @@ public final class PdfPreviewPane {
|
|||||||
StackPane.setAlignment(imageView, Pos.CENTER);
|
StackPane.setAlignment(imageView, Pos.CENTER);
|
||||||
StackPane.setAlignment(overlayLabel, Pos.CENTER);
|
StackPane.setAlignment(overlayLabel, Pos.CENTER);
|
||||||
StackPane.setAlignment(progressIndicator, Pos.CENTER);
|
StackPane.setAlignment(progressIndicator, Pos.CENTER);
|
||||||
VBox.setVgrow(viewStack, Priority.ALWAYS);
|
|
||||||
|
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.setId("pdf-preview-prev-button");
|
||||||
prevButton.setOnAction(e -> navigateToPreviousPage());
|
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.setId("pdf-preview-next-button");
|
||||||
nextButton.setOnAction(e -> navigateToNextPage());
|
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.setId("pdf-preview-page-label");
|
||||||
pageLabel.setStyle("-fx-text-fill: #555555;");
|
pageLabel.setStyle("-fx-text-fill: #555555;");
|
||||||
@@ -177,7 +273,7 @@ public final class PdfPreviewPane {
|
|||||||
navBar.setAlignment(Pos.CENTER);
|
navBar.setAlignment(Pos.CENTER);
|
||||||
navBar.setPadding(new Insets(4, 0, 4, 0));
|
navBar.setPadding(new Insets(4, 0, 4, 0));
|
||||||
|
|
||||||
root.getChildren().addAll(sectionTitle, viewStack, navBar);
|
root.getChildren().addAll(sectionTitle, scrollPane, navBar);
|
||||||
root.setPadding(new Insets(4, 0, 0, 0));
|
root.setPadding(new Insets(4, 0, 0, 0));
|
||||||
|
|
||||||
showPlaceholder();
|
showPlaceholder();
|
||||||
@@ -208,10 +304,11 @@ public final class PdfPreviewPane {
|
|||||||
clear();
|
clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentSourceFile = sourceFile;
|
currentSourceFile.set(sourceFile);
|
||||||
currentPage = 0;
|
currentPage = 0;
|
||||||
totalPages = -1;
|
totalPages = -1;
|
||||||
pageCache.clear();
|
pageCache.clear();
|
||||||
|
resetToFitView();
|
||||||
requestLoad(sourceFile);
|
requestLoad(sourceFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +319,7 @@ public final class PdfPreviewPane {
|
|||||||
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
*/
|
*/
|
||||||
public void clear() {
|
public void clear() {
|
||||||
currentSourceFile = null;
|
currentSourceFile.set(null);
|
||||||
currentPage = 0;
|
currentPage = 0;
|
||||||
totalPages = -1;
|
totalPages = -1;
|
||||||
pageCache.clear();
|
pageCache.clear();
|
||||||
@@ -230,6 +327,7 @@ public final class PdfPreviewPane {
|
|||||||
currentRequestSequence.incrementAndGet();
|
currentRequestSequence.incrementAndGet();
|
||||||
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
|
// Dokument auf dem Worker-Thread schließen, da PDDocument ausschließlich dort genutzt wird
|
||||||
executor.submit(this::closeCurrentDocumentOnWorker);
|
executor.submit(this::closeCurrentDocumentOnWorker);
|
||||||
|
resetToFitView();
|
||||||
imageView.setImage(null);
|
imageView.setImage(null);
|
||||||
showPlaceholder();
|
showPlaceholder();
|
||||||
updateNavigationButtons();
|
updateNavigationButtons();
|
||||||
@@ -287,6 +385,16 @@ public final class PdfPreviewPane {
|
|||||||
return progressIndicator;
|
return progressIndicator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
ScrollPane scrollPane() {
|
||||||
|
return scrollPane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. */
|
||||||
|
double zoomLevel() {
|
||||||
|
return zoomLevel;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Navigation -----------------------------------------------------------
|
// --- Navigation -----------------------------------------------------------
|
||||||
|
|
||||||
private void navigateToPreviousPage() {
|
private void navigateToPreviousPage() {
|
||||||
@@ -365,12 +473,13 @@ public final class PdfPreviewPane {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
PDDocument doc = Loader.loadPDF(ioFile);
|
PDDocument doc = Loader.loadPDF(ioFile);
|
||||||
currentDocument = doc;
|
currentDocument.set(doc);
|
||||||
currentRenderer = new PDFRenderer(doc);
|
PDFRenderer renderer = new PDFRenderer(doc);
|
||||||
|
currentRenderer.set(renderer);
|
||||||
|
|
||||||
int pages = Math.max(1, doc.getNumberOfPages());
|
int pages = Math.max(1, doc.getNumberOfPages());
|
||||||
BufferedImage buffered =
|
BufferedImage buffered =
|
||||||
currentRenderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
|
renderer.renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
|
||||||
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
|
Image fxImage = SwingFXUtils.toFXImage(buffered, null);
|
||||||
|
|
||||||
final int totalPagesFinal = pages;
|
final int totalPagesFinal = pages;
|
||||||
@@ -406,7 +515,7 @@ public final class PdfPreviewPane {
|
|||||||
* @param seq die Sequenznummer dieser Anforderung
|
* @param seq die Sequenznummer dieser Anforderung
|
||||||
*/
|
*/
|
||||||
private void renderPageOnWorker(int page, long seq) {
|
private void renderPageOnWorker(int page, long seq) {
|
||||||
PDFRenderer renderer = currentRenderer;
|
PDFRenderer renderer = currentRenderer.get();
|
||||||
if (renderer == null) {
|
if (renderer == null) {
|
||||||
// Dokument wurde zwischenzeitlich geschlossen – nichts zu tun
|
// Dokument wurde zwischenzeitlich geschlossen – nichts zu tun
|
||||||
return;
|
return;
|
||||||
@@ -435,9 +544,8 @@ public final class PdfPreviewPane {
|
|||||||
* auf dem Worker-Thread und ist idempotent.
|
* auf dem Worker-Thread und ist idempotent.
|
||||||
*/
|
*/
|
||||||
private void closeCurrentDocumentOnWorker() {
|
private void closeCurrentDocumentOnWorker() {
|
||||||
PDDocument doc = currentDocument;
|
PDDocument doc = currentDocument.getAndSet(null);
|
||||||
currentDocument = null;
|
currentRenderer.set(null);
|
||||||
currentRenderer = null;
|
|
||||||
if (doc != null) {
|
if (doc != null) {
|
||||||
try {
|
try {
|
||||||
doc.close();
|
doc.close();
|
||||||
@@ -463,6 +571,217 @@ public final class PdfPreviewPane {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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 ---------------------------------------------------
|
// --- UI-Zustandshelfer ---------------------------------------------------
|
||||||
|
|
||||||
private void showPlaceholder() {
|
private void showPlaceholder() {
|
||||||
@@ -506,7 +825,7 @@ public final class PdfPreviewPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void updateNavigationButtons() {
|
private void updateNavigationButtons() {
|
||||||
boolean canNavigate = enabled && currentSourceFile != null && totalPages > 0;
|
boolean canNavigate = enabled && currentSourceFile.get() != null && totalPages > 0;
|
||||||
prevButton.setDisable(!canNavigate || currentPage <= 1);
|
prevButton.setDisable(!canNavigate || currentPage <= 1);
|
||||||
nextButton.setDisable(!canNavigate || currentPage >= totalPages);
|
nextButton.setDisable(!canNavigate || currentPage >= totalPages);
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-5
@@ -3,6 +3,7 @@ package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
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.
|
* Zentrale Mapping-Klasse für die visuelle Darstellung von Verarbeitungsstatus in der GUI.
|
||||||
@@ -19,6 +20,9 @@ import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
|||||||
* Alle Methoden sind statisch.
|
* Alle Methoden sind statisch.
|
||||||
*/
|
*/
|
||||||
public final class ProcessingStatusPresentation {
|
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+)
|
// Icons (Unicode-Zeichen, zuverlässig darstellbar unter Windows 10+)
|
||||||
@@ -165,7 +169,7 @@ public final class ProcessingStatusPresentation {
|
|||||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
*/
|
*/
|
||||||
public static String iconFor(DocumentCompletionStatus status) {
|
public static String iconFor(DocumentCompletionStatus status) {
|
||||||
Objects.requireNonNull(status, "status darf nicht null sein");
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case SUCCESS -> ICON_SUCCESS;
|
case SUCCESS -> ICON_SUCCESS;
|
||||||
case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE;
|
case FAILED_RETRYABLE -> ICON_FAILED_RETRYABLE;
|
||||||
@@ -186,7 +190,7 @@ public final class ProcessingStatusPresentation {
|
|||||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
*/
|
*/
|
||||||
public static String cssColorFor(DocumentCompletionStatus status) {
|
public static String cssColorFor(DocumentCompletionStatus status) {
|
||||||
Objects.requireNonNull(status, "status darf nicht null sein");
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case SUCCESS -> COLOR_SUCCESS;
|
case SUCCESS -> COLOR_SUCCESS;
|
||||||
case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE;
|
case FAILED_RETRYABLE -> COLOR_FAILED_RETRYABLE;
|
||||||
@@ -204,7 +208,7 @@ public final class ProcessingStatusPresentation {
|
|||||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
*/
|
*/
|
||||||
public static String tooltipFor(DocumentCompletionStatus status) {
|
public static String tooltipFor(DocumentCompletionStatus status) {
|
||||||
Objects.requireNonNull(status, "status darf nicht null sein");
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case SUCCESS -> TOOLTIP_SUCCESS;
|
case SUCCESS -> TOOLTIP_SUCCESS;
|
||||||
case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE;
|
case FAILED_RETRYABLE -> TOOLTIP_FAILED_RETRYABLE;
|
||||||
@@ -223,7 +227,7 @@ public final class ProcessingStatusPresentation {
|
|||||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
*/
|
*/
|
||||||
public static String summaryCategoryFor(DocumentCompletionStatus status) {
|
public static String summaryCategoryFor(DocumentCompletionStatus status) {
|
||||||
Objects.requireNonNull(status, "status darf nicht null sein");
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case SUCCESS -> SUMMARY_CATEGORY_SUCCESS;
|
case SUCCESS -> SUMMARY_CATEGORY_SUCCESS;
|
||||||
case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE;
|
case FAILED_RETRYABLE -> SUMMARY_CATEGORY_FAILED_RETRYABLE;
|
||||||
@@ -242,7 +246,7 @@ public final class ProcessingStatusPresentation {
|
|||||||
* @throws NullPointerException wenn {@code status} {@code null} ist
|
* @throws NullPointerException wenn {@code status} {@code null} ist
|
||||||
*/
|
*/
|
||||||
public static StatusVisuals visualsFor(DocumentCompletionStatus status) {
|
public static StatusVisuals visualsFor(DocumentCompletionStatus status) {
|
||||||
Objects.requireNonNull(status, "status darf nicht null sein");
|
Objects.requireNonNull(status, STATUS_NOT_NULL);
|
||||||
return new StatusVisuals(
|
return new StatusVisuals(
|
||||||
iconFor(status),
|
iconFor(status),
|
||||||
cssColorFor(status),
|
cssColorFor(status),
|
||||||
@@ -250,6 +254,32 @@ public final class ProcessingStatusPresentation {
|
|||||||
summaryCategoryFor(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. */
|
/** Nicht instanziierbar – reine Utility-Klasse. */
|
||||||
private ProcessingStatusPresentation() {
|
private ProcessingStatusPresentation() {
|
||||||
throw new UnsupportedOperationException("Nicht instanziierbar");
|
throw new UnsupportedOperationException("Nicht instanziierbar");
|
||||||
|
|||||||
+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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ public record GuiMessageEntry(
|
|||||||
Objects.requireNonNull(severity, "severity must not be null");
|
Objects.requireNonNull(severity, "severity must not be null");
|
||||||
Objects.requireNonNull(text, "text must not be null");
|
Objects.requireNonNull(text, "text must not be null");
|
||||||
Objects.requireNonNull(timestamp, "timestamp must not be null");
|
Objects.requireNonNull(timestamp, "timestamp must not be null");
|
||||||
source = source == null ? Optional.empty() : source;
|
source = Objects.requireNonNullElse(source, Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+322
-97
@@ -15,6 +15,9 @@ import java.util.function.Supplier;
|
|||||||
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 de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiTooltipTexts;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.AiFailureMessageTranslator;
|
||||||
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun.ProcessingStatusPresentation;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
|
||||||
@@ -23,9 +26,13 @@ import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCa
|
|||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
|
import javafx.animation.PauseTransition;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
import javafx.util.Duration;
|
||||||
|
import javafx.util.StringConverter;
|
||||||
import javafx.beans.property.SimpleStringProperty;
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ListChangeListener;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import javafx.geometry.Insets;
|
import javafx.geometry.Insets;
|
||||||
import javafx.geometry.Pos;
|
import javafx.geometry.Pos;
|
||||||
@@ -57,6 +64,7 @@ import javafx.scene.layout.VBox;
|
|||||||
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
|
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
|
||||||
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
|
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
|
||||||
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
|
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
|
||||||
|
* Das Suchfeld ist mit einem 300-ms-Debounce ausgestattet (Live-Filter).
|
||||||
*
|
*
|
||||||
* <h2>Layout</h2>
|
* <h2>Layout</h2>
|
||||||
* <pre>
|
* <pre>
|
||||||
@@ -79,6 +87,11 @@ import javafx.scene.layout.VBox;
|
|||||||
* Verarbeitungslaufs deaktiviert.
|
* Verarbeitungslaufs deaktiviert.
|
||||||
*/
|
*/
|
||||||
public final class GuiHistoryTab {
|
public final class GuiHistoryTab {
|
||||||
|
private static final String BOLD_STYLE = "-fx-font-weight: bold;";
|
||||||
|
private static final String NO_ERROR_DETAILS_MSG = "Keine Fehlerdetails gespeichert.";
|
||||||
|
private static final String NO_CONFIG_LOADED_MSG = "Keine Konfiguration geladen.";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class);
|
private static final Logger LOG = LogManager.getLogger(GuiHistoryTab.class);
|
||||||
|
|
||||||
@@ -107,7 +120,7 @@ public final class GuiHistoryTab {
|
|||||||
private final Tab tab = new Tab(TAB_TITLE);
|
private final Tab tab = new Tab(TAB_TITLE);
|
||||||
|
|
||||||
private final TextField searchField = new TextField();
|
private final TextField searchField = new TextField();
|
||||||
private final ComboBox<String> statusFilterBox = new ComboBox<>();
|
private final ComboBox<ProcessingStatus> statusFilterBox = new ComboBox<>();
|
||||||
private final Button refreshButton = new Button("Aktualisieren");
|
private final Button refreshButton = new Button("Aktualisieren");
|
||||||
|
|
||||||
private final TableView<DocumentHistoryRow> overviewTable = new TableView<>();
|
private final TableView<DocumentHistoryRow> overviewTable = new TableView<>();
|
||||||
@@ -127,6 +140,7 @@ public final class GuiHistoryTab {
|
|||||||
|
|
||||||
private final TableView<ProcessingAttempt> attemptsTable = new TableView<>();
|
private final TableView<ProcessingAttempt> attemptsTable = new TableView<>();
|
||||||
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
|
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
|
||||||
|
private final TextArea failureArea = new TextArea();
|
||||||
private final TextArea reasoningArea = new TextArea();
|
private final TextArea reasoningArea = new TextArea();
|
||||||
|
|
||||||
private final Button resetButton = new Button("Status zurücksetzen");
|
private final Button resetButton = new Button("Status zurücksetzen");
|
||||||
@@ -135,6 +149,21 @@ public final class GuiHistoryTab {
|
|||||||
// ---- Zustand --------------------------------------------------------
|
// ---- Zustand --------------------------------------------------------
|
||||||
private final ExecutorService workerPool;
|
private final ExecutorService workerPool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce-Timer für das Suchfeld: löst {@link #loadOverview()} aus, sobald
|
||||||
|
* 300 ms nach der letzten Texteingabe vergangen sind.
|
||||||
|
*/
|
||||||
|
private final PauseTransition searchDebounce = new PauseTransition(Duration.millis(300));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sperre für DB-lesende und DB-schreibende Aktionen während einer
|
||||||
|
* laufenden Datenbank-Anlage (vgl. „Neue Datenbank anlegen"). Wird auf {@code true}
|
||||||
|
* gesetzt, solange die Anlage einer neuen SQLite-Datenbank läuft, und nach Erfolg
|
||||||
|
* oder Fehler zuverlässig zurückgesetzt. Während dieser Zeit sind Suche, Filter,
|
||||||
|
* Aktualisieren, Status-Reset und Löschen deaktiviert.
|
||||||
|
*/
|
||||||
|
private boolean dbBusy = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt den Historien-Tab.
|
* Erzeugt den Historien-Tab.
|
||||||
*
|
*
|
||||||
@@ -189,6 +218,62 @@ public final class GuiHistoryTab {
|
|||||||
loadOverview();
|
loadOverview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird aufgerufen, wenn ein Verarbeitungslauf beendet wurde, damit Aktionsbuttons
|
||||||
|
* wieder aktiviert werden können, falls ein Dokument in der Tabelle selektiert ist.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void notifyRunEnded() {
|
||||||
|
if (!overviewTable.getSelectionModel().getSelectedItems().isEmpty()) {
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
deleteButton.setDisable(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schaltet die DB-Busy-Sperre des Verlauf-Tabs an oder aus.
|
||||||
|
* <p>
|
||||||
|
* Während der Sperre sind Suche, Statusfilter, Aktualisieren, Status-Reset und
|
||||||
|
* Eintrag-Löschen deaktiviert. Wird typischerweise vom Workspace aufgerufen,
|
||||||
|
* solange eine neue SQLite-Datenbank angelegt und aktiviert wird.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*
|
||||||
|
* @param busy {@code true} aktiviert die Sperre, {@code false} hebt sie wieder auf
|
||||||
|
*/
|
||||||
|
public void setDbBusy(boolean busy) {
|
||||||
|
this.dbBusy = busy;
|
||||||
|
searchField.setDisable(busy);
|
||||||
|
statusFilterBox.setDisable(busy);
|
||||||
|
refreshButton.setDisable(busy);
|
||||||
|
if (busy) {
|
||||||
|
resetButton.setDisable(true);
|
||||||
|
deleteButton.setDisable(true);
|
||||||
|
} else if (!overviewTable.getSelectionModel().getSelectedItems().isEmpty()
|
||||||
|
&& !runningCheck.getAsBoolean()) {
|
||||||
|
resetButton.setDisable(false);
|
||||||
|
deleteButton.setDisable(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Übersicht erneut, sofern keine DB-Busy-Sperre aktiv ist.
|
||||||
|
* <p>
|
||||||
|
* Wird vom Workspace nach erfolgreichem Datenbank-Wechsel aufgerufen, damit der
|
||||||
|
* Detailbereich und die Liste die neue (leere) Datenbank wiedergeben.
|
||||||
|
* <p>
|
||||||
|
* Muss auf dem JavaFX Application Thread aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public void reloadAfterDatabaseSwitch() {
|
||||||
|
// Selektion aufheben, damit der Detailbereich nicht mit Stammdaten
|
||||||
|
// aus der vorherigen Datenbank weiterzeigt.
|
||||||
|
overviewTable.getSelectionModel().clearSelection();
|
||||||
|
overviewItems.clear();
|
||||||
|
clearDetailPane();
|
||||||
|
loadOverview();
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// UI-Aufbau
|
// UI-Aufbau
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -200,10 +285,14 @@ public final class GuiHistoryTab {
|
|||||||
Tooltip.install(searchField, new Tooltip(
|
Tooltip.install(searchField, new Tooltip(
|
||||||
"Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal)."));
|
"Freitextsuche über Quell- und Zieldateiname (Groß-/Kleinschreibung egal)."));
|
||||||
|
|
||||||
statusFilterBox.getItems().add("Alle Status");
|
statusFilterBox.setConverter(new StringConverter<>() {
|
||||||
for (ProcessingStatus s : ProcessingStatus.values()) {
|
@Override public String toString(ProcessingStatus s) {
|
||||||
statusFilterBox.getItems().add(s.name());
|
return s == null ? "Alle Status" : ProcessingStatusPresentation.displayTextFor(s);
|
||||||
}
|
}
|
||||||
|
@Override public ProcessingStatus fromString(String text) { return null; }
|
||||||
|
});
|
||||||
|
statusFilterBox.getItems().add(null); // "Alle Status"
|
||||||
|
statusFilterBox.getItems().addAll(ProcessingStatus.values());
|
||||||
statusFilterBox.getSelectionModel().selectFirst();
|
statusFilterBox.getSelectionModel().selectFirst();
|
||||||
Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen."));
|
Tooltip.install(statusFilterBox, new Tooltip("Status-Filter: nur Einträge mit diesem Status anzeigen."));
|
||||||
|
|
||||||
@@ -262,12 +351,13 @@ public final class GuiHistoryTab {
|
|||||||
|
|
||||||
private void buildOverviewTable() {
|
private void buildOverviewTable() {
|
||||||
overviewTable.setItems(overviewItems);
|
overviewTable.setItems(overviewItems);
|
||||||
overviewTable.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
overviewTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||||
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
|
overviewTable.setPlaceholder(new Label(EMPTY_DB_TEXT));
|
||||||
overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
overviewTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
||||||
|
|
||||||
// Status-Icon-Spalte
|
// Status-Icon-Spalte
|
||||||
TableColumn<DocumentHistoryRow, String> statusCol = new TableColumn<>("Status");
|
TableColumn<DocumentHistoryRow, String> statusCol = new TableColumn<>();
|
||||||
|
statusCol.setGraphic(columnHeader("Status", GuiTooltipTexts.VERLAUF_COL_STATUS));
|
||||||
statusCol.setCellValueFactory(cell ->
|
statusCol.setCellValueFactory(cell ->
|
||||||
new SimpleStringProperty(statusIcon(cell.getValue().overallStatus())));
|
new SimpleStringProperty(statusIcon(cell.getValue().overallStatus())));
|
||||||
statusCol.setCellFactory(col -> new TableCell<>() {
|
statusCol.setCellFactory(col -> new TableCell<>() {
|
||||||
@@ -289,27 +379,31 @@ public final class GuiHistoryTab {
|
|||||||
statusCol.setMaxWidth(70);
|
statusCol.setMaxWidth(70);
|
||||||
|
|
||||||
// Quelldateiname
|
// Quelldateiname
|
||||||
TableColumn<DocumentHistoryRow, String> sourceCol = new TableColumn<>("Quelldatei");
|
TableColumn<DocumentHistoryRow, String> sourceCol = new TableColumn<>();
|
||||||
|
sourceCol.setGraphic(columnHeader("Quelldatei", GuiTooltipTexts.VERLAUF_COL_QUELLDATEI));
|
||||||
sourceCol.setCellValueFactory(cell ->
|
sourceCol.setCellValueFactory(cell ->
|
||||||
new SimpleStringProperty(cell.getValue().sourceFileName()));
|
new SimpleStringProperty(cell.getValue().sourceFileName()));
|
||||||
sourceCol.setCellFactory(col -> ellipsisCell());
|
sourceCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
// Zieldateiname
|
// Zieldateiname
|
||||||
TableColumn<DocumentHistoryRow, String> targetCol = new TableColumn<>("Zieldatei");
|
TableColumn<DocumentHistoryRow, String> targetCol = new TableColumn<>();
|
||||||
|
targetCol.setGraphic(columnHeader("Zieldatei", GuiTooltipTexts.VERLAUF_COL_ZIELDATEI));
|
||||||
targetCol.setCellValueFactory(cell ->
|
targetCol.setCellValueFactory(cell ->
|
||||||
new SimpleStringProperty(
|
new SimpleStringProperty(
|
||||||
cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : "—"));
|
cell.getValue().targetFileName() != null ? cell.getValue().targetFileName() : "—"));
|
||||||
targetCol.setCellFactory(col -> ellipsisCell());
|
targetCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
// Letzter Versuch
|
// Letzter Versuch
|
||||||
TableColumn<DocumentHistoryRow, String> updatedCol = new TableColumn<>("Letzter Versuch");
|
TableColumn<DocumentHistoryRow, String> updatedCol = new TableColumn<>();
|
||||||
|
updatedCol.setGraphic(columnHeader("Letzter Versuch", GuiTooltipTexts.VERLAUF_COL_LETZTER_VERSUCH));
|
||||||
updatedCol.setCellValueFactory(cell ->
|
updatedCol.setCellValueFactory(cell ->
|
||||||
new SimpleStringProperty(formatInstant(cell.getValue().updatedAt())));
|
new SimpleStringProperty(formatInstant(cell.getValue().updatedAt())));
|
||||||
updatedCol.setPrefWidth(140);
|
updatedCol.setPrefWidth(140);
|
||||||
updatedCol.setMaxWidth(160);
|
updatedCol.setMaxWidth(160);
|
||||||
|
|
||||||
// Anzahl Versuche
|
// Anzahl Versuche
|
||||||
TableColumn<DocumentHistoryRow, String> countCol = new TableColumn<>("Versuche");
|
TableColumn<DocumentHistoryRow, String> countCol = new TableColumn<>();
|
||||||
|
countCol.setGraphic(columnHeader("Versuche", GuiTooltipTexts.VERLAUF_COL_VERSUCHE));
|
||||||
countCol.setCellValueFactory(cell ->
|
countCol.setCellValueFactory(cell ->
|
||||||
new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount())));
|
new SimpleStringProperty(String.valueOf(cell.getValue().attemptCount())));
|
||||||
countCol.setPrefWidth(70);
|
countCol.setPrefWidth(70);
|
||||||
@@ -332,24 +426,36 @@ public final class GuiHistoryTab {
|
|||||||
addDetailRow(5, "Aktualisiert:", detailUpdatedLabel);
|
addDetailRow(5, "Aktualisiert:", detailUpdatedLabel);
|
||||||
|
|
||||||
Label detailTitle = new Label("Dokument-Details");
|
Label detailTitle = new Label("Dokument-Details");
|
||||||
detailTitle.setStyle("-fx-font-weight: bold;");
|
detailTitle.setStyle(BOLD_STYLE);
|
||||||
|
|
||||||
// Versuche-Tabelle
|
// Versuche-Tabelle
|
||||||
buildAttemptsTable();
|
buildAttemptsTable();
|
||||||
Label attemptsTitle = new Label("Verarbeitungsversuche");
|
Label attemptsTitle = new Label("Verarbeitungsversuche");
|
||||||
attemptsTitle.setStyle("-fx-font-weight: bold;");
|
attemptsTitle.setStyle(BOLD_STYLE);
|
||||||
|
|
||||||
|
// Fehlerursache (aus letztem Fehler-Versuch)
|
||||||
|
failureArea.setEditable(false);
|
||||||
|
failureArea.setWrapText(true);
|
||||||
|
failureArea.setPrefRowCount(3);
|
||||||
|
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
|
||||||
|
Label failureTitle = new Label("Fehlerursache (letzter Fehler-Versuch)");
|
||||||
|
failureTitle.setStyle(BOLD_STYLE);
|
||||||
|
|
||||||
|
failureArea.setTooltip(new Tooltip(GuiTooltipTexts.VERLAUF_FAILURE_AREA));
|
||||||
|
|
||||||
// KI-Begründung
|
// KI-Begründung
|
||||||
reasoningArea.setEditable(false);
|
reasoningArea.setEditable(false);
|
||||||
reasoningArea.setWrapText(true);
|
reasoningArea.setWrapText(true);
|
||||||
reasoningArea.setPrefRowCount(4);
|
reasoningArea.setPrefRowCount(4);
|
||||||
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||||
|
reasoningArea.setTooltip(new Tooltip(GuiTooltipTexts.VERLAUF_REASONING_AREA));
|
||||||
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
|
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
|
||||||
reasoningTitle.setStyle("-fx-font-weight: bold;");
|
reasoningTitle.setStyle(BOLD_STYLE);
|
||||||
|
|
||||||
VBox rightPane = new VBox(8,
|
VBox rightPane = new VBox(8,
|
||||||
detailTitle, detailGrid,
|
detailTitle, detailGrid,
|
||||||
attemptsTitle, attemptsTable,
|
attemptsTitle, attemptsTable,
|
||||||
|
failureTitle, failureArea,
|
||||||
reasoningTitle, reasoningArea);
|
reasoningTitle, reasoningArea);
|
||||||
rightPane.setPadding(new Insets(4, 8, 4, 4));
|
rightPane.setPadding(new Insets(4, 8, 4, 4));
|
||||||
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
|
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
|
||||||
@@ -370,37 +476,43 @@ public final class GuiHistoryTab {
|
|||||||
attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
attemptsTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
||||||
attemptsTable.setPrefHeight(150);
|
attemptsTable.setPrefHeight(150);
|
||||||
|
|
||||||
TableColumn<ProcessingAttempt, String> numCol = new TableColumn<>("#");
|
TableColumn<ProcessingAttempt, String> numCol = new TableColumn<>();
|
||||||
|
numCol.setGraphic(columnHeader("#", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_NR));
|
||||||
numCol.setCellValueFactory(c ->
|
numCol.setCellValueFactory(c ->
|
||||||
new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber())));
|
new SimpleStringProperty(String.valueOf(c.getValue().attemptNumber())));
|
||||||
numCol.setPrefWidth(40);
|
numCol.setPrefWidth(40);
|
||||||
numCol.setMaxWidth(50);
|
numCol.setMaxWidth(50);
|
||||||
|
|
||||||
TableColumn<ProcessingAttempt, String> dateCol = new TableColumn<>("Datum");
|
TableColumn<ProcessingAttempt, String> dateCol = new TableColumn<>();
|
||||||
|
dateCol.setGraphic(columnHeader("Datum", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_DATUM));
|
||||||
dateCol.setCellValueFactory(c ->
|
dateCol.setCellValueFactory(c ->
|
||||||
new SimpleStringProperty(formatInstant(c.getValue().endedAt())));
|
new SimpleStringProperty(formatInstant(c.getValue().endedAt())));
|
||||||
dateCol.setPrefWidth(130);
|
dateCol.setPrefWidth(130);
|
||||||
dateCol.setMaxWidth(150);
|
dateCol.setMaxWidth(150);
|
||||||
|
|
||||||
TableColumn<ProcessingAttempt, String> statusCol = new TableColumn<>("Status");
|
TableColumn<ProcessingAttempt, String> statusCol = new TableColumn<>();
|
||||||
|
statusCol.setGraphic(columnHeader("Status", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_STATUS));
|
||||||
statusCol.setCellValueFactory(c ->
|
statusCol.setCellValueFactory(c ->
|
||||||
new SimpleStringProperty(
|
new SimpleStringProperty(
|
||||||
statusIcon(c.getValue().status()) + " " + c.getValue().status().name()));
|
ProcessingStatusPresentation.displayTextFor(c.getValue().status())));
|
||||||
statusCol.setPrefWidth(140);
|
statusCol.setPrefWidth(160);
|
||||||
|
|
||||||
TableColumn<ProcessingAttempt, String> providerCol = new TableColumn<>("Provider");
|
TableColumn<ProcessingAttempt, String> providerCol = new TableColumn<>();
|
||||||
|
providerCol.setGraphic(columnHeader("Provider", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_PROVIDER));
|
||||||
providerCol.setCellValueFactory(c ->
|
providerCol.setCellValueFactory(c ->
|
||||||
new SimpleStringProperty(
|
new SimpleStringProperty(
|
||||||
c.getValue().aiProvider() != null ? c.getValue().aiProvider() : "—"));
|
c.getValue().aiProvider() != null ? c.getValue().aiProvider() : "—"));
|
||||||
providerCol.setPrefWidth(90);
|
providerCol.setPrefWidth(90);
|
||||||
|
|
||||||
TableColumn<ProcessingAttempt, String> modelCol = new TableColumn<>("Modell");
|
TableColumn<ProcessingAttempt, String> modelCol = new TableColumn<>();
|
||||||
|
modelCol.setGraphic(columnHeader("Modell", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_MODELL));
|
||||||
modelCol.setCellValueFactory(c ->
|
modelCol.setCellValueFactory(c ->
|
||||||
new SimpleStringProperty(
|
new SimpleStringProperty(
|
||||||
c.getValue().modelName() != null ? c.getValue().modelName() : "—"));
|
c.getValue().modelName() != null ? c.getValue().modelName() : "—"));
|
||||||
modelCol.setCellFactory(col -> ellipsisCell());
|
modelCol.setCellFactory(col -> ellipsisCell());
|
||||||
|
|
||||||
TableColumn<ProcessingAttempt, String> fileNameCol = new TableColumn<>("Vorgeschlagener Name");
|
TableColumn<ProcessingAttempt, String> fileNameCol = new TableColumn<>();
|
||||||
|
fileNameCol.setGraphic(columnHeader("Vorgeschlagener Name", GuiTooltipTexts.VERLAUF_VERSUCHE_COL_VORGESCHLAGENER_NAME));
|
||||||
fileNameCol.setCellValueFactory(c ->
|
fileNameCol.setCellValueFactory(c ->
|
||||||
new SimpleStringProperty(
|
new SimpleStringProperty(
|
||||||
c.getValue().finalTargetFileName() != null
|
c.getValue().finalTargetFileName() != null
|
||||||
@@ -417,23 +529,34 @@ public final class GuiHistoryTab {
|
|||||||
private void wireEvents() {
|
private void wireEvents() {
|
||||||
refreshButton.setOnAction(e -> loadOverview());
|
refreshButton.setOnAction(e -> loadOverview());
|
||||||
|
|
||||||
// Debounce-artige Aktualisierung bei Texteingabe: direkte Suche bei Enter,
|
// Live-Filter: 300-ms-Debounce bei jeder Texteingabe
|
||||||
// sonst über Fokus-Verlust oder expliziten Aktualisieren-Button
|
searchDebounce.setOnFinished(e -> loadOverview());
|
||||||
searchField.setOnAction(e -> loadOverview());
|
searchField.textProperty().addListener((obs, old, val) -> searchDebounce.playFromStart());
|
||||||
|
// Enter-Taste: sofort suchen, Debounce-Timer stoppen
|
||||||
|
searchField.setOnAction(e -> { searchDebounce.stop(); loadOverview(); });
|
||||||
|
|
||||||
statusFilterBox.setOnAction(e -> loadOverview());
|
statusFilterBox.setOnAction(e -> loadOverview());
|
||||||
|
|
||||||
// Detailbereich bei Zeilenselektion
|
// Detailbereich und Buttons bei Selektionsänderung aktualisieren
|
||||||
overviewTable.getSelectionModel().selectedItemProperty().addListener(
|
overviewTable.getSelectionModel().getSelectedItems().addListener(
|
||||||
(obs, old, selected) -> {
|
(ListChangeListener<DocumentHistoryRow>) change -> {
|
||||||
if (selected == null) {
|
List<DocumentHistoryRow> sel =
|
||||||
|
List.copyOf(overviewTable.getSelectionModel().getSelectedItems());
|
||||||
|
boolean running = runningCheck.getAsBoolean();
|
||||||
|
if (sel.isEmpty()) {
|
||||||
clearDetailPane();
|
clearDetailPane();
|
||||||
resetButton.setDisable(true);
|
resetButton.setDisable(true);
|
||||||
deleteButton.setDisable(true);
|
deleteButton.setDisable(true);
|
||||||
|
} else if (sel.size() == 1) {
|
||||||
|
resetButton.setDisable(running);
|
||||||
|
deleteButton.setDisable(running);
|
||||||
|
loadDetails(sel.get(0).fingerprint());
|
||||||
} else {
|
} else {
|
||||||
resetButton.setDisable(runningCheck.getAsBoolean());
|
// Mehrfachauswahl: Detail-Bereich löschen, Buttons aktivieren
|
||||||
deleteButton.setDisable(runningCheck.getAsBoolean());
|
clearDetailPane();
|
||||||
loadDetails(selected.fingerprint());
|
resetButton.setDisable(running);
|
||||||
|
deleteButton.setDisable(running);
|
||||||
|
statusBarLabel.setText(sel.size() + " Einträge ausgewählt.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -461,14 +584,13 @@ public final class GuiHistoryTab {
|
|||||||
Path configPath = configPathSupplier.get();
|
Path configPath = configPathSupplier.get();
|
||||||
if (configPath == null) {
|
if (configPath == null) {
|
||||||
statusBarLabel.setText("Keine Konfiguration geladen – bitte zuerst eine Konfigurationsdatei öffnen.");
|
statusBarLabel.setText("Keine Konfiguration geladen – bitte zuerst eine Konfigurationsdatei öffnen.");
|
||||||
overviewTable.setPlaceholder(new Label("Keine Konfiguration geladen."));
|
overviewTable.setPlaceholder(new Label(NO_CONFIG_LOADED_MSG));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String searchText = searchField.getText();
|
String searchText = searchField.getText();
|
||||||
String selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem();
|
ProcessingStatus selectedStatus = statusFilterBox.getSelectionModel().getSelectedItem();
|
||||||
String statusFilter = (selectedStatus == null || "Alle Status".equals(selectedStatus))
|
String statusFilter = selectedStatus == null ? null : selectedStatus.name();
|
||||||
? null : selectedStatus;
|
|
||||||
|
|
||||||
HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT);
|
HistoryQuery query = new HistoryQuery(searchText, statusFilter, HistoryQuery.DEFAULT_LIMIT);
|
||||||
|
|
||||||
@@ -543,48 +665,76 @@ public final class GuiHistoryTab {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
|
List<DocumentHistoryRow> selectedItems =
|
||||||
if (selected == null) return;
|
List.copyOf(overviewTable.getSelectionModel().getSelectedItems());
|
||||||
|
if (selectedItems.isEmpty()) return;
|
||||||
|
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
showInfo(NO_CONFIG_LOADED_MSG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long successCount = selectedItems.stream()
|
||||||
|
.filter(r -> r.overallStatus() == ProcessingStatus.SUCCESS)
|
||||||
|
.count();
|
||||||
|
|
||||||
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
|
||||||
confirm.setTitle("Status zurücksetzen");
|
confirm.setTitle("Status zurücksetzen");
|
||||||
confirm.setHeaderText("Status zurücksetzen?");
|
confirm.setHeaderText("Status zurücksetzen?");
|
||||||
confirm.setContentText(
|
confirm.setContentText(buildResetConfirmationText(selectedItems, successCount));
|
||||||
"Setzt den Status des Dokuments auf READY_FOR_AI zurück.\n"
|
|
||||||
+ "Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n"
|
|
||||||
+ "Die Versuchshistorie bleibt vollständig erhalten.\n\n"
|
|
||||||
+ "Das Dokument wird beim nächsten Verarbeitungslauf erneut verarbeitet.\n\n"
|
|
||||||
+ "Quelldatei: " + selected.sourceFileName());
|
|
||||||
Optional<ButtonType> choice = confirm.showAndWait();
|
Optional<ButtonType> choice = confirm.showAndWait();
|
||||||
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
||||||
|
|
||||||
DocumentFingerprint fp = selected.fingerprint();
|
|
||||||
Path configPath = configPathSupplier.get();
|
|
||||||
if (configPath == null) {
|
|
||||||
showInfo("Keine Konfiguration geladen.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resetButton.setDisable(true);
|
resetButton.setDisable(true);
|
||||||
deleteButton.setDisable(true);
|
deleteButton.setDisable(true);
|
||||||
statusBarLabel.setText("Status wird zurückgesetzt …");
|
statusBarLabel.setText("Status wird zurückgesetzt …");
|
||||||
|
|
||||||
workerPool.submit(() -> {
|
workerPool.submit(() -> {
|
||||||
|
int okCount = 0;
|
||||||
|
int errCount = 0;
|
||||||
|
for (DocumentHistoryRow row : selectedItems) {
|
||||||
try {
|
try {
|
||||||
resetPort.resetStatus(configPath, fp);
|
resetPort.resetStatus(configPath, row.fingerprint());
|
||||||
LOG.info("Status-Reset durchgeführt für Fingerprint: {}", fp.sha256Hex());
|
LOG.info("Status-Reset durchgeführt für Fingerprint: {}", row.fingerprint().sha256Hex());
|
||||||
|
okCount++;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Status-Reset fehlgeschlagen für {}: {}",
|
||||||
|
row.fingerprint().sha256Hex(), ex.getMessage(), ex);
|
||||||
|
errCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final int ok = okCount, err = errCount;
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
statusBarLabel.setText("Status erfolgreich zurückgesetzt.");
|
if (err == 0) {
|
||||||
|
statusBarLabel.setText("Status erfolgreich zurückgesetzt: " + ok + " Eintrag/Einträge.");
|
||||||
|
} else {
|
||||||
|
statusBarLabel.setText("Status zurückgesetzt: " + ok + " OK, " + err + " Fehler.");
|
||||||
|
}
|
||||||
loadOverview();
|
loadOverview();
|
||||||
});
|
});
|
||||||
} catch (Exception ex) {
|
|
||||||
LOG.error("Status-Reset fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
|
|
||||||
Platform.runLater(() -> {
|
|
||||||
statusBarLabel.setText("Fehler beim Status-Reset: " + ex.getMessage());
|
|
||||||
resetButton.setDisable(false);
|
|
||||||
deleteButton.setDisable(false);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
private static String buildResetConfirmationText(List<DocumentHistoryRow> selectedItems, long successCount) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Setzt den Status auf READY_FOR_AI zurück.\n");
|
||||||
|
sb.append("Fehlerzähler und letzter Fehlerzeitpunkt werden gelöscht.\n");
|
||||||
|
sb.append("Die Versuchshistorie bleibt vollständig erhalten.\n\n");
|
||||||
|
if (selectedItems.size() == 1) {
|
||||||
|
sb.append("Quelldatei: ").append(selectedItems.get(0).sourceFileName());
|
||||||
|
} else {
|
||||||
|
sb.append(selectedItems.size()).append(" Einträge werden zurückgesetzt.");
|
||||||
|
}
|
||||||
|
if (successCount > 0) {
|
||||||
|
sb.append("\n\nHinweis: ").append(successCount)
|
||||||
|
.append(" der ausgewählten Einträge ")
|
||||||
|
.append(successCount == 1 ? "hat" : "haben")
|
||||||
|
.append(" Status \"Erfolgreich\". ")
|
||||||
|
.append(successCount == 1 ? "Dieser Eintrag wird" : "Diese Einträge werden")
|
||||||
|
.append(" erneut verarbeitet.");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleDeleteAction() {
|
private void handleDeleteAction() {
|
||||||
@@ -593,47 +743,63 @@ public final class GuiHistoryTab {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DocumentHistoryRow selected = overviewTable.getSelectionModel().getSelectedItem();
|
List<DocumentHistoryRow> selectedItems =
|
||||||
if (selected == null) return;
|
List.copyOf(overviewTable.getSelectionModel().getSelectedItems());
|
||||||
|
if (selectedItems.isEmpty()) return;
|
||||||
|
|
||||||
|
Path configPath = configPathSupplier.get();
|
||||||
|
if (configPath == null) {
|
||||||
|
showInfo(NO_CONFIG_LOADED_MSG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String contentText;
|
||||||
|
if (selectedItems.size() == 1) {
|
||||||
|
contentText = "Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n"
|
||||||
|
+ "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
|
||||||
|
+ "Quelldatei: " + selectedItems.get(0).sourceFileName();
|
||||||
|
} else {
|
||||||
|
contentText = selectedItems.size() + " Einträge werden mit allen Versuchen unwiderruflich gelöscht.\n"
|
||||||
|
+ "Diese Aktion kann nicht rückgängig gemacht werden.";
|
||||||
|
}
|
||||||
|
|
||||||
Alert confirm = new Alert(Alert.AlertType.WARNING);
|
Alert confirm = new Alert(Alert.AlertType.WARNING);
|
||||||
confirm.setTitle("Eintrag löschen");
|
confirm.setTitle("Eintrag löschen");
|
||||||
confirm.setHeaderText("Eintrag vollständig löschen?");
|
confirm.setHeaderText(selectedItems.size() == 1 ? "Eintrag vollständig löschen?"
|
||||||
confirm.setContentText(
|
: selectedItems.size() + " Einträge vollständig löschen?");
|
||||||
"Der Stammsatz und ALLE Verarbeitungsversuche werden unwiderruflich gelöscht.\n"
|
confirm.setContentText(contentText);
|
||||||
+ "Diese Aktion kann nicht rückgängig gemacht werden.\n\n"
|
|
||||||
+ "Quelldatei: " + selected.sourceFileName());
|
|
||||||
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
|
confirm.getButtonTypes().setAll(ButtonType.OK, ButtonType.CANCEL);
|
||||||
Optional<ButtonType> choice = confirm.showAndWait();
|
Optional<ButtonType> choice = confirm.showAndWait();
|
||||||
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
if (choice.isEmpty() || choice.get() != ButtonType.OK) return;
|
||||||
|
|
||||||
DocumentFingerprint fp = selected.fingerprint();
|
|
||||||
Path configPath = configPathSupplier.get();
|
|
||||||
if (configPath == null) {
|
|
||||||
showInfo("Keine Konfiguration geladen.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resetButton.setDisable(true);
|
resetButton.setDisable(true);
|
||||||
deleteButton.setDisable(true);
|
deleteButton.setDisable(true);
|
||||||
statusBarLabel.setText("Eintrag wird gelöscht …");
|
statusBarLabel.setText("Einträge werden gelöscht …");
|
||||||
|
|
||||||
workerPool.submit(() -> {
|
workerPool.submit(() -> {
|
||||||
|
int okCount = 0;
|
||||||
|
int errCount = 0;
|
||||||
|
for (DocumentHistoryRow row : selectedItems) {
|
||||||
try {
|
try {
|
||||||
deletePort.deleteHistory(configPath, fp);
|
deletePort.deleteHistory(configPath, row.fingerprint());
|
||||||
LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", fp.sha256Hex());
|
LOG.info("Dokumenteintrag gelöscht für Fingerprint: {}", row.fingerprint().sha256Hex());
|
||||||
|
okCount++;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Löschen fehlgeschlagen für {}: {}",
|
||||||
|
row.fingerprint().sha256Hex(), ex.getMessage(), ex);
|
||||||
|
errCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final int ok = okCount, err = errCount;
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
statusBarLabel.setText("Eintrag erfolgreich gelöscht.");
|
if (err == 0) {
|
||||||
|
statusBarLabel.setText("Gelöscht: " + ok + " Eintrag/Einträge.");
|
||||||
|
} else {
|
||||||
|
statusBarLabel.setText("Gelöscht: " + ok + " OK, " + err + " Fehler.");
|
||||||
|
}
|
||||||
clearDetailPane();
|
clearDetailPane();
|
||||||
loadOverview();
|
loadOverview();
|
||||||
});
|
});
|
||||||
} catch (Exception ex) {
|
|
||||||
LOG.error("Löschen fehlgeschlagen für {}: {}", fp.sha256Hex(), ex.getMessage(), ex);
|
|
||||||
Platform.runLater(() -> {
|
|
||||||
statusBarLabel.setText("Fehler beim Löschen: " + ex.getMessage());
|
|
||||||
resetButton.setDisable(false);
|
|
||||||
deleteButton.setDisable(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,8 +815,7 @@ public final class GuiHistoryTab {
|
|||||||
detailSourceFileLabel.setText(record.lastKnownSourceFileName());
|
detailSourceFileLabel.setText(record.lastKnownSourceFileName());
|
||||||
detailSourcePathLabel.setText(record.lastKnownSourceLocator().value());
|
detailSourcePathLabel.setText(record.lastKnownSourceLocator().value());
|
||||||
detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value()));
|
detailSourcePathLabel.setTooltip(new Tooltip(record.lastKnownSourceLocator().value()));
|
||||||
String icon = statusIcon(record.overallStatus());
|
detailStatusLabel.setText(ProcessingStatusPresentation.displayTextFor(record.overallStatus()));
|
||||||
detailStatusLabel.setText(icon + " " + record.overallStatus().name());
|
|
||||||
detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";");
|
detailStatusLabel.setStyle("-fx-text-fill: " + statusColor(record.overallStatus()) + ";");
|
||||||
detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus())));
|
detailStatusLabel.setTooltip(new Tooltip(statusTooltip(record.overallStatus())));
|
||||||
detailCreatedLabel.setText(formatInstant(record.createdAt()));
|
detailCreatedLabel.setText(formatInstant(record.createdAt()));
|
||||||
@@ -658,33 +823,78 @@ public final class GuiHistoryTab {
|
|||||||
|
|
||||||
attemptsItems.setAll(result.attempts());
|
attemptsItems.setAll(result.attempts());
|
||||||
|
|
||||||
// Neuesten Versuch selektieren und Begründung anzeigen
|
// Fehlerursache aus letztem Fehler-Versuch anzeigen
|
||||||
if (!result.attempts().isEmpty()) {
|
showLastFailureMessage(result.attempts(), record.overallStatus());
|
||||||
ProcessingAttempt last = result.attempts().get(result.attempts().size() - 1);
|
|
||||||
|
selectLatestAttemptAndShowReasoning(result.attempts());
|
||||||
|
attemptsTable.getSelectionModel().selectedItemProperty().addListener(
|
||||||
|
(obs, old, attempt) -> onAttemptSelected(attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectLatestAttemptAndShowReasoning(java.util.List<ProcessingAttempt> attempts) {
|
||||||
|
if (!attempts.isEmpty()) {
|
||||||
|
ProcessingAttempt last = attempts.get(attempts.size() - 1);
|
||||||
attemptsTable.getSelectionModel().select(last);
|
attemptsTable.getSelectionModel().select(last);
|
||||||
showReasoning(last);
|
showReasoning(last);
|
||||||
} else {
|
} else {
|
||||||
reasoningArea.setText(NO_REASONING_TEXT);
|
reasoningArea.setText("");
|
||||||
|
reasoningArea.setPromptText(NO_REASONING_TEXT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// KI-Begründung bei Versuchs-Selektion aktualisieren
|
private void onAttemptSelected(ProcessingAttempt attempt) {
|
||||||
attemptsTable.getSelectionModel().selectedItemProperty().addListener(
|
|
||||||
(obs, old, attempt) -> {
|
|
||||||
if (attempt != null) {
|
if (attempt != null) {
|
||||||
showReasoning(attempt);
|
showReasoning(attempt);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die Fehlerursache des letzten Fehlschlags im Fehlerursache-Bereich an.
|
||||||
|
* Relevant bei Status FAILED_FINAL, FAILED_RETRYABLE und SKIPPED_FINAL_FAILURE.
|
||||||
|
* Bei fehlendem Eintrag oder leerem Feld wird ein Platzhalter-Text gesetzt.
|
||||||
|
*/
|
||||||
|
private void showLastFailureMessage(List<ProcessingAttempt> attempts, ProcessingStatus overallStatus) {
|
||||||
|
boolean failureRelevant = overallStatus == ProcessingStatus.FAILED_FINAL
|
||||||
|
|| overallStatus == ProcessingStatus.FAILED_RETRYABLE
|
||||||
|
|| overallStatus == ProcessingStatus.SKIPPED_FINAL_FAILURE;
|
||||||
|
|
||||||
|
if (!failureRelevant || attempts.isEmpty()) {
|
||||||
|
failureArea.setText("");
|
||||||
|
failureArea.setPromptText("Keine Fehlerdetails für diesen Status.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzten Versuch mit nicht-leerem failure_message suchen (absteigend nach attempt_number)
|
||||||
|
String failureMessage = null;
|
||||||
|
for (int i = attempts.size() - 1; i >= 0; i--) {
|
||||||
|
String msg = attempts.get(i).failureMessage();
|
||||||
|
if (msg != null && !msg.isBlank()) {
|
||||||
|
failureMessage = msg;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
failureArea.setText(failureMessage != null
|
||||||
|
? AiFailureMessageTranslator.translate(failureMessage) : "");
|
||||||
|
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showReasoning(ProcessingAttempt attempt) {
|
private void showReasoning(ProcessingAttempt attempt) {
|
||||||
String reasoning = attempt.aiReasoning();
|
String reasoning = attempt.aiReasoning();
|
||||||
reasoningArea.setText(reasoning != null && !reasoning.isBlank()
|
if (reasoning != null && !reasoning.isBlank()) {
|
||||||
? reasoning : NO_REASONING_TEXT);
|
reasoningArea.setText(reasoning);
|
||||||
|
reasoningArea.setPromptText("");
|
||||||
|
} else {
|
||||||
|
reasoningArea.setText("");
|
||||||
|
reasoningArea.setPromptText(NO_REASONING_TEXT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearDetailPane() {
|
private void clearDetailPane() {
|
||||||
clearDetailFields();
|
clearDetailFields();
|
||||||
attemptsItems.clear();
|
attemptsItems.clear();
|
||||||
|
failureArea.setText("");
|
||||||
|
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
|
||||||
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
reasoningArea.setText(DETAIL_PLACEHOLDER);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,7 +917,7 @@ public final class GuiHistoryTab {
|
|||||||
|
|
||||||
private void addDetailRow(int row, String labelText, Label valueLabel) {
|
private void addDetailRow(int row, String labelText, Label valueLabel) {
|
||||||
Label label = new Label(labelText);
|
Label label = new Label(labelText);
|
||||||
label.setStyle("-fx-font-weight: bold;");
|
label.setStyle(BOLD_STYLE);
|
||||||
valueLabel.setMaxWidth(Double.MAX_VALUE);
|
valueLabel.setMaxWidth(Double.MAX_VALUE);
|
||||||
GridPane.setHgrow(valueLabel, Priority.ALWAYS);
|
GridPane.setHgrow(valueLabel, Priority.ALWAYS);
|
||||||
detailGrid.add(label, 0, row);
|
detailGrid.add(label, 0, row);
|
||||||
@@ -762,6 +972,21 @@ public final class GuiHistoryTab {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein Label für den Spaltenkopf einer TableColumn mit Tooltip.
|
||||||
|
* Wird anstelle von {@code column.setText()} verwendet, da TableColumn
|
||||||
|
* kein direktes {@code setTooltip()} unterstützt.
|
||||||
|
*
|
||||||
|
* @param title sichtbarer Spaltentext
|
||||||
|
* @param tooltip Tooltip-Text
|
||||||
|
* @return ein Label mit gesetztem Tooltip
|
||||||
|
*/
|
||||||
|
private static Label columnHeader(String title, String tooltip) {
|
||||||
|
Label label = new Label(title);
|
||||||
|
label.setTooltip(new Tooltip(tooltip));
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
private static <T> TableCell<T, String> ellipsisCell() {
|
private static <T> TableCell<T, String> ellipsisCell() {
|
||||||
return new TableCell<>() {
|
return new TableCell<>() {
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+10
-7
@@ -244,16 +244,18 @@ class GuiAdapterSmokeTest {
|
|||||||
"The 'Speichern' button must be visible");
|
"The 'Speichern' button must be visible");
|
||||||
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
|
||||||
"The 'Speichern unter' button must be visible");
|
"The 'Speichern unter' button must be visible");
|
||||||
assertEquals(4, workspace.tabPane().getTabs().size(),
|
assertEquals(5, workspace.tabPane().getTabs().size(),
|
||||||
"Configuration tab, processing-run tab, history tab and prompt editor tab must all be present");
|
"Configuration tab, processing-run tab, scheduler tab, history tab and prompt editor tab must all be present");
|
||||||
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
|
||||||
"The first tab must use the configuration label");
|
"The first tab must use the configuration label");
|
||||||
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
|
||||||
"The second tab must host the processing-run view");
|
"The second tab must host the processing-run view");
|
||||||
assertEquals("Verlauf", workspace.tabPane().getTabs().get(2).getText(),
|
assertEquals("Scheduler", workspace.tabPane().getTabs().get(2).getText(),
|
||||||
"The third tab must host the history view");
|
"The third tab must host the scheduler control");
|
||||||
assertEquals("Prompt", workspace.tabPane().getTabs().get(3).getText(),
|
assertEquals("Verlauf", workspace.tabPane().getTabs().get(3).getText(),
|
||||||
"The fourth tab must host the prompt editor");
|
"The fourth tab must host the history view");
|
||||||
|
assertEquals("Prompt", workspace.tabPane().getTabs().get(4).getText(),
|
||||||
|
"The fifth tab must host the prompt editor");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
|
||||||
String.join(",", workspace.sectionTitles()),
|
String.join(",", workspace.sectionTitles()),
|
||||||
@@ -419,7 +421,8 @@ class GuiAdapterSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
|||||||
+2
-1
@@ -345,7 +345,8 @@ class GuiEditorFieldBindingTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
|||||||
+6
-3
@@ -137,7 +137,8 @@ class GuiEditorIntegrationTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -287,7 +288,8 @@ class GuiEditorIntegrationTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -371,7 +373,8 @@ class GuiEditorIntegrationTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
|||||||
+10
-5
@@ -208,7 +208,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -347,7 +348,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -471,7 +473,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -599,7 +602,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -698,7 +702,8 @@ class GuiEditorRegressionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
|||||||
+4
-2
@@ -142,7 +142,8 @@ class GuiEditorValidationSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -272,7 +273,8 @@ class GuiEditorValidationSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
|||||||
-3
@@ -23,10 +23,7 @@ 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.GuiHistoryOverviewPort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
|
||||||
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
|
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryDetailsUseCase.HistoryDetailsResult;
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultHistoryOverviewUseCase.HistoryOverviewResult;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.scene.control.Tab;
|
import javafx.scene.control.Tab;
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -336,7 +336,8 @@ class GuiMessageAreaSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -478,7 +479,8 @@ class GuiMessageAreaSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -565,7 +567,8 @@ class GuiMessageAreaSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -888,7 +891,8 @@ class GuiMessageAreaSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
|||||||
+2
-1
@@ -529,7 +529,8 @@ class GuiModelCatalogSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
|||||||
+81
@@ -252,6 +252,47 @@ class GuiPromptEditorTabSmokeTest {
|
|||||||
assertFalse(dirtyRef.get(), "Dirty-State muss false sein wenn Datei nicht gefunden wurde");
|
assertFalse(dirtyRef.get(), "Dirty-State muss false sein wenn Datei nicht gefunden wurde");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyConfigurationChanged_shouldResetDirtyStateAndTitle() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||||
|
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
// Laden und anschliessend Inhalt aendern, um Dirty-State zu erzeugen
|
||||||
|
editorTab.loadPromptAsync();
|
||||||
|
editorTab.resetToDefault();
|
||||||
|
// Vorbedingung: Dirty-State muss aktiv sein
|
||||||
|
assertTrue(editorTab.hasDirtyContent(),
|
||||||
|
"Vorbedingung: Dirty-State muss nach resetToDefault aktiv sein");
|
||||||
|
|
||||||
|
// Konfiguration wechseln – Dirty-State und Titel sollen zurueckgesetzt werden
|
||||||
|
editorTab.notifyConfigurationChanged(new SyncPromptEditorPort(), "/new/prompt.txt", 80);
|
||||||
|
|
||||||
|
dirtyRef.set(editorTab.hasDirtyContent());
|
||||||
|
titleRef.set(editorTab.tab().getText());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(dirtyRef.get(),
|
||||||
|
"Dirty-State muss nach notifyConfigurationChanged false sein");
|
||||||
|
assertFalse(titleRef.get().contains("*"),
|
||||||
|
"Tab-Titel darf nach notifyConfigurationChanged keinen Asterisk enthalten; Titel war: "
|
||||||
|
+ titleRef.get());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void tabTitle_shouldContainAsterisk_afterEditWithLoadedBaseline() throws Exception {
|
void tabTitle_shouldContainAsterisk_afterEditWithLoadedBaseline() throws Exception {
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
@@ -287,4 +328,44 @@ class GuiPromptEditorTabSmokeTest {
|
|||||||
"Tab-Titel muss nach Bearbeitung (resetToDefault) einen Asterisk enthalten; Titel war: "
|
"Tab-Titel muss nach Bearbeitung (resetToDefault) einen Asterisk enthalten; Titel war: "
|
||||||
+ titleRef.get());
|
+ titleRef.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void discardChanges_shouldResetDirtyStateAndTitle() throws Exception {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> fxError = new AtomicReference<>();
|
||||||
|
AtomicBoolean dirtyRef = new AtomicBoolean(true);
|
||||||
|
AtomicReference<String> titleRef = new AtomicReference<>();
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
SyncPromptEditorPort port = new SyncPromptEditorPort();
|
||||||
|
GuiPromptEditorTab editorTab = buildSyncTab(port);
|
||||||
|
editorTab.loadPromptAsync();
|
||||||
|
editorTab.resetToDefault();
|
||||||
|
// Vorbedingung: Dirty-State muss aktiv sein
|
||||||
|
assertTrue(editorTab.hasDirtyContent(),
|
||||||
|
"Vorbedingung: Dirty-State muss nach resetToDefault aktiv sein");
|
||||||
|
|
||||||
|
// Verwerfen simulieren
|
||||||
|
editorTab.discardChanges();
|
||||||
|
|
||||||
|
dirtyRef.set(editorTab.hasDirtyContent());
|
||||||
|
titleRef.set(editorTab.tab().getText());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
fxError.set(t);
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
if (fxError.get() != null) {
|
||||||
|
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
|
||||||
|
}
|
||||||
|
assertFalse(dirtyRef.get(),
|
||||||
|
"Dirty-State muss nach discardChanges false sein");
|
||||||
|
assertFalse(titleRef.get().contains("*"),
|
||||||
|
"Tab-Titel darf nach discardChanges keinen Asterisk enthalten; Titel war: "
|
||||||
|
+ titleRef.get());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-2
@@ -1,11 +1,9 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
|||||||
+12
-8
@@ -39,7 +39,7 @@ import javafx.scene.control.Button;
|
|||||||
* {@code technical-tests-button}.</li>
|
* {@code technical-tests-button}.</li>
|
||||||
* <li>Triggering the coordinator synchronously populates {@code pendingMessages}
|
* <li>Triggering the coordinator synchronously populates {@code pendingMessages}
|
||||||
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li>
|
* with entries tagged {@link GuiTechnicalTestCoordinator#SOURCE_TAG}.</li>
|
||||||
* <li>A second trigger appends a fresh batch of test entries (accumulation semantics).</li>
|
* <li>A second trigger replaces the previous batch of test entries.</li>
|
||||||
* <li>The post-result callback is invoked after the result is applied.</li>
|
* <li>The post-result callback is invoked after the result is applied.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
@@ -138,12 +138,12 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Smoke test: after one trigger, the number of entries tagged SOURCE_TAG equals
|
* Smoke test: after one trigger, the number of entries tagged SOURCE_TAG equals
|
||||||
* 11 (one per checkpoint) plus 1 summary entry = 12.
|
* 12 (one per checkpoint) plus 1 summary entry = 13.
|
||||||
*
|
*
|
||||||
* @throws Exception if the FX thread task fails or times out
|
* @throws Exception if the FX thread task fails or times out
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void trigger_producesElevenCheckpointEntriesPlusSummary() throws Exception {
|
void trigger_producesTwelveCheckpointEntriesPlusSummary() throws Exception {
|
||||||
runOnFx(() -> {
|
runOnFx(() -> {
|
||||||
List<GuiMessageEntry> messages = new ArrayList<>();
|
List<GuiMessageEntry> messages = new ArrayList<>();
|
||||||
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
GuiTechnicalTestCoordinator coordinator = buildSyncCoordinator(messages, report -> { });
|
||||||
@@ -155,9 +155,9 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
&& GuiTechnicalTestCoordinator.SOURCE_TAG.equals(m.source().get()))
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
// 11 checkpoint entries + 1 summary entry = 12
|
// 12 checkpoint entries + 1 summary entry = 13
|
||||||
assertEquals(12, taggedCount,
|
assertEquals(13, taggedCount,
|
||||||
"Expected 11 checkpoint entries + 1 summary entry = 12 tagged messages");
|
"Expected 12 checkpoint entries + 1 summary entry = 13 tagged messages");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,12 +256,14 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
noOpPathCheckPort(),
|
noOpPathCheckPort(),
|
||||||
noOpProviderService());
|
noOpProviderService(),
|
||||||
|
() -> java.util.Optional.empty());
|
||||||
|
|
||||||
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
GuiTechnicalTestCoordinator coordinator = new GuiTechnicalTestCoordinator(
|
||||||
orchestrator,
|
orchestrator,
|
||||||
currentInput::get, // always reads the current reference
|
currentInput::get, // always reads the current reference
|
||||||
() -> "",
|
() -> "",
|
||||||
|
() -> "",
|
||||||
messages,
|
messages,
|
||||||
report -> { });
|
report -> { });
|
||||||
|
|
||||||
@@ -365,7 +367,8 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
TechnicalTestOrchestrator orchestrator = new TechnicalTestOrchestrator(
|
||||||
new EditorConfigurationValidator(),
|
new EditorConfigurationValidator(),
|
||||||
noOpPathCheckPort(),
|
noOpPathCheckPort(),
|
||||||
noOpProviderService());
|
noOpProviderService(),
|
||||||
|
() -> java.util.Optional.empty());
|
||||||
|
|
||||||
EditorValidationInput blankInput = new EditorValidationInput(
|
EditorValidationInput blankInput = new EditorValidationInput(
|
||||||
"claude",
|
"claude",
|
||||||
@@ -380,6 +383,7 @@ class GuiTechnicalTestCoordinatorSmokeTest {
|
|||||||
orchestrator,
|
orchestrator,
|
||||||
() -> blankInput,
|
() -> blankInput,
|
||||||
() -> "",
|
() -> "",
|
||||||
|
() -> "",
|
||||||
messages,
|
messages,
|
||||||
postResultCallback);
|
postResultCallback);
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -806,7 +806,8 @@ class GuiUnsavedChangesGuardSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -851,7 +852,8 @@ class GuiUnsavedChangesGuardSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent())),
|
(fam99, pv99) -> de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor.absent()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
|||||||
+4
-2
@@ -323,7 +323,8 @@ class GuiValidateActionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
noOpApiKeyResolutionPort())),
|
noOpApiKeyResolutionPort()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
@@ -390,7 +391,8 @@ class GuiValidateActionSmokeTest {
|
|||||||
},
|
},
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ProviderTechnicalTestService(
|
||||||
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
req99 -> new de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult.IncompleteConfiguration(req99.providerIdentifier(), "kein Port im Test"),
|
||||||
noOpApiKeyResolutionPort())),
|
noOpApiKeyResolutionPort()),
|
||||||
|
() -> java.util.Optional.empty()),
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionExecutionService(
|
||||||
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceCreationPort() {
|
||||||
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
@Override public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome createDirectory(de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionSuggestion.CreateDirectory s) { return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome.NotAttempted(s, "kein Port im Test"); }
|
||||||
|
|||||||
+36
-12
@@ -119,9 +119,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
|
|||||||
void startReset_invokesResetPortAndDispatchesResult() {
|
void startReset_invokesResetPortAndDispatchesResult() {
|
||||||
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
|
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
|
||||||
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
// intentionally empty
|
||||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
}
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
|
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
|
||||||
captured.set(result);
|
captured.set(result);
|
||||||
}
|
}
|
||||||
@@ -170,9 +176,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
|
|||||||
void startReset_portThrowsException_mapsToAllFailures() {
|
void startReset_portThrowsException_mapsToAllFailures() {
|
||||||
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
|
AtomicReference<ResetDocumentStatusResult> captured = new AtomicReference<>();
|
||||||
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
// intentionally empty
|
||||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
}
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
|
@Override public void onResetCompleted(ResetDocumentStatusResult result) {
|
||||||
captured.set(result);
|
captured.set(result);
|
||||||
}
|
}
|
||||||
@@ -198,9 +210,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
|
|||||||
void listenerDefaultOnResetCompleted_doesNotThrow() {
|
void listenerDefaultOnResetCompleted_doesNotThrow() {
|
||||||
// Verify the default implementation is safe to call.
|
// Verify the default implementation is safe to call.
|
||||||
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
GuiBatchRunCoordinator.Listener listener = new GuiBatchRunCoordinator.Listener() {
|
||||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
// intentionally empty
|
||||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
}
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
};
|
};
|
||||||
listener.onResetCompleted(new ResetDocumentStatusResult(0, Set.of(), Map.of()));
|
listener.onResetCompleted(new ResetDocumentStatusResult(0, Set.of(), Map.of()));
|
||||||
}
|
}
|
||||||
@@ -223,9 +241,15 @@ class GuiBatchRunCoordinatorMiniRunTest {
|
|||||||
|
|
||||||
private static GuiBatchRunCoordinator.Listener noOpListener() {
|
private static GuiBatchRunCoordinator.Listener noOpListener() {
|
||||||
return new GuiBatchRunCoordinator.Listener() {
|
return new GuiBatchRunCoordinator.Listener() {
|
||||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
// intentionally empty
|
||||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
}
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-7
@@ -247,8 +247,12 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
launcher, syncThreadFactory(), syncDispatcher(),
|
launcher, syncThreadFactory(), syncDispatcher(),
|
||||||
new GuiBatchRunCoordinator.Listener() {
|
new GuiBatchRunCoordinator.Listener() {
|
||||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
captured.set(outcome);
|
captured.set(outcome);
|
||||||
}
|
}
|
||||||
@@ -270,8 +274,12 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
GuiBatchRunCoordinator coordinator = new GuiBatchRunCoordinator(
|
||||||
launcher, syncThreadFactory(), syncDispatcher(),
|
launcher, syncThreadFactory(), syncDispatcher(),
|
||||||
new GuiBatchRunCoordinator.Listener() {
|
new GuiBatchRunCoordinator.Listener() {
|
||||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
captured.set(outcome);
|
captured.set(outcome);
|
||||||
}
|
}
|
||||||
@@ -322,9 +330,15 @@ class GuiBatchRunCoordinatorTest {
|
|||||||
|
|
||||||
private static GuiBatchRunCoordinator.Listener noOpListener() {
|
private static GuiBatchRunCoordinator.Listener noOpListener() {
|
||||||
return new GuiBatchRunCoordinator.Listener() {
|
return new GuiBatchRunCoordinator.Listener() {
|
||||||
@Override public void onRunStarted(RunId runId, int totalCandidates) { }
|
@Override public void onRunStarted(RunId runId, int totalCandidates) {
|
||||||
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) { }
|
// intentionally empty
|
||||||
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) { }
|
}
|
||||||
|
@Override public void onDocumentCompleted(GuiBatchRunResultRow row) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void onRunEnded(RunSummary summary, GuiBatchRunLaunchOutcome outcome) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
|
import org.apache.pdfbox.pdmodel.PDPage;
|
||||||
|
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||||
|
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||||
|
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javafx.application.Platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless (Monocle) Tests, die echte PDF-Dateien rendern, damit die
|
||||||
|
* Worker-Thread-Pfade {@code loadAndRenderFirstPageOnWorker} und
|
||||||
|
* {@code renderPageOnWorker} tatsächlich ausgeführt werden.
|
||||||
|
*/
|
||||||
|
class PdfPreviewPaneRenderingTest {
|
||||||
|
|
||||||
|
private static final long FX_TIMEOUT_SECONDS = 10;
|
||||||
|
private static final long WORKER_TIMEOUT_SECONDS = 15;
|
||||||
|
private static final AtomicBoolean PLATFORM_STARTED = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void startPlatform() throws InterruptedException {
|
||||||
|
Platform.setImplicitExit(false);
|
||||||
|
if (PLATFORM_STARTED.compareAndSet(false, true)) {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
Platform.startup(latch::countDown);
|
||||||
|
} catch (IllegalStateException alreadyStarted) {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadSource_realSinglePagePdf_pageLabelShowsRenderedPage(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path pdfFile = tempDir.resolve("single-page.pdf");
|
||||||
|
createPdfWithPages(pdfFile, 1);
|
||||||
|
|
||||||
|
AtomicReference<PdfPreviewPane> paneRef = new AtomicReference<>();
|
||||||
|
CountDownLatch firstPageRendered = new CountDownLatch(1);
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
PdfPreviewPane pane = new PdfPreviewPane();
|
||||||
|
paneRef.set(pane);
|
||||||
|
pane.pageLabel().textProperty().addListener((obs, old, newText) -> {
|
||||||
|
if (newText != null && newText.contains("Seite 1 / 1")) {
|
||||||
|
firstPageRendered.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pane.loadSource(pdfFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(firstPageRendered.await(WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Erste Seite eines einseitigen PDFs muss innerhalb der Worker-Timeout-Frist gerendert werden");
|
||||||
|
|
||||||
|
runOnFx(() -> paneRef.get().shutdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void navigateToNextPage_multiPagePdf_rendersSecondPage(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path pdfFile = tempDir.resolve("multi-page.pdf");
|
||||||
|
createPdfWithPages(pdfFile, 3);
|
||||||
|
|
||||||
|
AtomicReference<PdfPreviewPane> paneRef = new AtomicReference<>();
|
||||||
|
CountDownLatch firstPageRendered = new CountDownLatch(1);
|
||||||
|
CountDownLatch secondPageRendered = new CountDownLatch(1);
|
||||||
|
AtomicBoolean firstSeen = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
runOnFx(() -> {
|
||||||
|
PdfPreviewPane pane = new PdfPreviewPane();
|
||||||
|
paneRef.set(pane);
|
||||||
|
pane.pageLabel().textProperty().addListener((obs, old, newText) -> {
|
||||||
|
if (newText == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newText.contains("Seite 1 / 3") && firstSeen.compareAndSet(false, true)) {
|
||||||
|
firstPageRendered.countDown();
|
||||||
|
} else if (newText.contains("Seite 2 / 3")) {
|
||||||
|
secondPageRendered.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pane.loadSource(pdfFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(firstPageRendered.await(WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Erste Seite muss innerhalb der Worker-Timeout-Frist gerendert werden");
|
||||||
|
|
||||||
|
// Auf zweite Seite navigieren – triggert renderPageOnWorker
|
||||||
|
runOnFx(() -> paneRef.get().nextButton().fire());
|
||||||
|
|
||||||
|
assertTrue(secondPageRendered.await(WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS),
|
||||||
|
"Zweite Seite muss nach Klick auf Weiter gerendert werden");
|
||||||
|
|
||||||
|
runOnFx(() -> paneRef.get().shutdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static void createPdfWithPages(Path outputPath, int pages) throws IOException {
|
||||||
|
try (PDDocument doc = new PDDocument()) {
|
||||||
|
for (int i = 1; i <= pages; i++) {
|
||||||
|
PDPage page = new PDPage();
|
||||||
|
doc.addPage(page);
|
||||||
|
try (PDPageContentStream stream = new PDPageContentStream(doc, page)) {
|
||||||
|
stream.beginText();
|
||||||
|
stream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||||
|
stream.newLineAtOffset(50, 700);
|
||||||
|
stream.showText("Testseite " + i);
|
||||||
|
stream.endText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc.save(outputPath.toFile());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runOnFx(Runnable action) throws InterruptedException {
|
||||||
|
CountDownLatch done = new CountDownLatch(1);
|
||||||
|
AtomicReference<Throwable> error = new AtomicReference<>();
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
error.set(t);
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(done.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS), "FX-Thread Timeout");
|
||||||
|
if (error.get() != null) {
|
||||||
|
throw new AssertionError(error.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-1
@@ -6,7 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>de.gecheckt</groupId>
|
||||||
|
<artifactId>pdf-umbenenner-parent</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>pdf-umbenenner-adapter-in-scheduler</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Inbound-Adapter: autonomer Scheduler-Betrieb.
|
||||||
|
|
||||||
|
Abhängigkeitsrichtung (hexagonale Architektur):
|
||||||
|
adapter-in-scheduler → application → domain
|
||||||
|
|
||||||
|
KEIN Rückwärtsverweis auf pdf-umbenenner-bootstrap: das Bootstrap-Modul
|
||||||
|
verdrahtet den Scheduler und hängt selbst von diesem Modul ab – eine
|
||||||
|
umgekehrte Abhängigkeit würde einen Zyklus erzeugen.
|
||||||
|
|
||||||
|
ApplicationRunContext (package-private im Bootstrap-Modul) ist von hier
|
||||||
|
aus nicht direkt erreichbar. Die Schnittstelle zwischen Bootstrap und
|
||||||
|
diesem Modul wird über das BatchRunTrigger-Functional-Interface realisiert,
|
||||||
|
das im Bootstrap-Modul liegt und beim Start injiziert wird.
|
||||||
|
|
||||||
|
JavaFX ist bewusst ausgeschlossen: dieser Adapter läuft ohne Benutzeroberfläche.
|
||||||
|
|
||||||
|
maven-shade-plugin ist bewusst ausgeschlossen: das ausführbare JAR wird
|
||||||
|
ausschließlich im Bootstrap-Modul per Shade-Plugin erzeugt.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Interner Abhängigkeiten: Inbound-Adapter bezieht Ports und Use-Cases
|
||||||
|
ausschließlich aus der Application-Schicht -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.gecheckt</groupId>
|
||||||
|
<artifactId>pdf-umbenenner-application</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Logging -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.logging.log4j</groupId>
|
||||||
|
<artifactId>log4j-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test-Abhängigkeiten -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.logging.log4j</groupId>
|
||||||
|
<artifactId>log4j-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.assertj</groupId>
|
||||||
|
<artifactId>assertj-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<!--
|
||||||
|
flatten-maven-plugin: wird vom Parent geerbt und löst ${revision} in
|
||||||
|
installierten POMs auf. Keine eigene Konfiguration erforderlich –
|
||||||
|
der Eintrag ist nur zur bewussten Dokumentation dieser Erbschaftsentscheidung
|
||||||
|
vorhanden.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>jacoco-check</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>check</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<rules>
|
||||||
|
<rule>
|
||||||
|
<element>BUNDLE</element>
|
||||||
|
<limits>
|
||||||
|
<limit>
|
||||||
|
<counter>LINE</counter>
|
||||||
|
<value>COVEREDRATIO</value>
|
||||||
|
<minimum>0.80</minimum>
|
||||||
|
</limit>
|
||||||
|
<limit>
|
||||||
|
<counter>BRANCH</counter>
|
||||||
|
<value>COVEREDRATIO</value>
|
||||||
|
<minimum>0.70</minimum>
|
||||||
|
</limit>
|
||||||
|
</limits>
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.pitest</groupId>
|
||||||
|
<artifactId>pitest-maven</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>pitest</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>mutationCoverage</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<!--
|
||||||
|
PIT wird für diesen Adapter explizit deaktiviert. Der Parent
|
||||||
|
setzt skip=true als Standardwert; hier wird das bewusst
|
||||||
|
wiederholt dokumentiert. Mutations-Tests werden erst
|
||||||
|
aktiviert, wenn echte Produktionslogik vorliegt.
|
||||||
|
-->
|
||||||
|
<skip>true</skip>
|
||||||
|
<coverageThreshold>0</coverageThreshold>
|
||||||
|
<mutationThreshold>0</mutationThreshold>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsWriteException;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.channels.FileLock;
|
||||||
|
import java.nio.channels.OverlappingFileLockException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementiert {@link ConfigurationFileLockPort} und {@link SchedulerSettingsPort}
|
||||||
|
* auf Basis eines gemeinsam genutzten {@link FileChannel}.
|
||||||
|
* <p>
|
||||||
|
* Der exklusive OS-Lock auf die {@code .properties}-Datei wird über
|
||||||
|
* {@link FileChannel#tryLock()} mit einer Deadline-Wiederholschleife erworben.
|
||||||
|
* Solange der Lock gehalten wird, erfolgen Schreibvorgänge direkt über
|
||||||
|
* den bereits offenen Kanal (Truncate → Position(0) → Write → Force).
|
||||||
|
* Ohne aktiven Lock werden Schreibvorgänge über eine temporäre Datei
|
||||||
|
* und {@link Files#move} mit {@code ATOMIC_MOVE} und {@code REPLACE_EXISTING}
|
||||||
|
* durchgeführt.
|
||||||
|
* <p>
|
||||||
|
* Beide Ports teilen den internen {@link FileChannel}, damit
|
||||||
|
* Settings-Schreibvorgänge auch während eines aktiven OS-Locks korrekt
|
||||||
|
* in die Konfigurationsdatei durchgeschrieben werden können.
|
||||||
|
* <p>
|
||||||
|
* Instanzen dieser Klasse sind <em>nicht</em> Thread-sicher. Der Aufrufer
|
||||||
|
* ist für die Serialisierung konkurrierender Zugriffe verantwortlich.
|
||||||
|
*/
|
||||||
|
public class FileChannelConfigurationAccessAdapter
|
||||||
|
implements ConfigurationFileLockPort, SchedulerSettingsPort {
|
||||||
|
|
||||||
|
private static final Logger logger =
|
||||||
|
LogManager.getLogger(FileChannelConfigurationAccessAdapter.class);
|
||||||
|
|
||||||
|
private static final long ACQUIRE_TIMEOUT_MS = 3000L;
|
||||||
|
private static final long ACQUIRE_RETRY_INTERVAL_MS = 100L;
|
||||||
|
|
||||||
|
private static final String KEY_INTERVAL = "scheduler.interval.seconds";
|
||||||
|
|
||||||
|
private final Path configFile;
|
||||||
|
|
||||||
|
private FileChannel channel;
|
||||||
|
private FileLock fileLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Adapter für die angegebene Konfigurationsdatei.
|
||||||
|
*
|
||||||
|
* @param configFile Pfad zur {@code .properties}-Konfigurationsdatei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public FileChannelConfigurationAccessAdapter(Path configFile) {
|
||||||
|
this.configFile = Objects.requireNonNull(configFile, "configFile darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// ConfigurationFileLockPort
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erwirbt den exklusiven OS-Lock auf die Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Ist der Lock bereits durch diese Instanz gehalten, hat dieser Aufruf
|
||||||
|
* keine Wirkung (idempotent). Andernfalls wird der {@link FileChannel}
|
||||||
|
* mit {@link StandardOpenOption#READ} und {@link StandardOpenOption#WRITE}
|
||||||
|
* geöffnet und {@link FileChannel#tryLock()} in einer Schleife mit
|
||||||
|
* {@value ACQUIRE_RETRY_INTERVAL_MS}-ms-Pausen versucht. Schlägt der
|
||||||
|
* Erwerb innerhalb von {@value ACQUIRE_TIMEOUT_MS} ms fehl, werden
|
||||||
|
* Kanal und Lock geschlossen und eine {@link ConfigurationFileLockException}
|
||||||
|
* geworfen.
|
||||||
|
*
|
||||||
|
* @throws ConfigurationFileLockException wenn der Lock nicht innerhalb der
|
||||||
|
* Deadline erworben werden kann, ein I/O-Fehler auftritt oder der
|
||||||
|
* Thread unterbrochen wird
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void acquireLock() throws ConfigurationFileLockException {
|
||||||
|
if (isLocked()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long deadline = System.currentTimeMillis() + ACQUIRE_TIMEOUT_MS;
|
||||||
|
try {
|
||||||
|
channel = FileChannel.open(configFile,
|
||||||
|
StandardOpenOption.READ, StandardOpenOption.WRITE);
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
FileLock lock = channel.tryLock();
|
||||||
|
if (lock != null) {
|
||||||
|
this.fileLock = lock;
|
||||||
|
logger.debug("OS-Lock auf Konfigurationsdatei erworben: {}", configFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (OverlappingFileLockException e) {
|
||||||
|
// Dieselbe JVM hält bereits einen Lock auf diesen Dateibereich;
|
||||||
|
// wird wie ein nicht verfügbarer Lock behandelt.
|
||||||
|
}
|
||||||
|
if (System.currentTimeMillis() >= deadline) {
|
||||||
|
closeChannelSilently();
|
||||||
|
throw new ConfigurationFileLockException(
|
||||||
|
"Konfigurationsdatei konnte nicht gesperrt werden: "
|
||||||
|
+ "Timeout nach " + ACQUIRE_TIMEOUT_MS + " ms. Datei: " + configFile);
|
||||||
|
}
|
||||||
|
Thread.sleep(ACQUIRE_RETRY_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
closeChannelSilently();
|
||||||
|
throw new ConfigurationFileLockException(
|
||||||
|
"Lock-Erwerb auf Konfigurationsdatei wurde unterbrochen.", e);
|
||||||
|
} catch (IOException e) {
|
||||||
|
closeChannelSilently();
|
||||||
|
throw new ConfigurationFileLockException(
|
||||||
|
"Konfigurationsdatei konnte nicht geöffnet oder gesperrt werden: "
|
||||||
|
+ configFile, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den exklusiven Lock frei und schließt den {@link FileChannel}.
|
||||||
|
* <p>
|
||||||
|
* Ist kein Lock aktiv, hat dieser Aufruf keine Wirkung (idempotent).
|
||||||
|
* Aufgetretene I/O-Fehler werden geloggt und still übergangen.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void releaseLock() {
|
||||||
|
if (fileLock != null) {
|
||||||
|
try {
|
||||||
|
fileLock.release();
|
||||||
|
logger.debug("OS-Lock auf Konfigurationsdatei freigegeben: {}", configFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warn("Fehler beim Freigeben des FileLock für {}.", configFile, e);
|
||||||
|
}
|
||||||
|
fileLock = null;
|
||||||
|
}
|
||||||
|
closeChannelSilently();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der Lock aktuell von dieser Instanz gehalten wird.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn der Lock aktiv und gültig ist
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isLocked() {
|
||||||
|
return fileLock != null && fileLock.isValid();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SchedulerSettingsPort
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die aktuellen Scheduler-Einstellungen aus der Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Fehlt ein Key oder ist er leer, wird der jeweilige Standardwert aus
|
||||||
|
* {@link SchedulerSettings#defaults()} zurückgegeben. Ungültige Werte
|
||||||
|
* (z.B. nicht-numerisches Intervall) führen ebenfalls zu den Standardwerten,
|
||||||
|
* nicht zu einer Exception.
|
||||||
|
*
|
||||||
|
* @return aktuelle Scheduler-Einstellungen; nie {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SchedulerSettings loadSettings() {
|
||||||
|
Properties props = new Properties();
|
||||||
|
try {
|
||||||
|
String content = Files.readString(configFile, StandardCharsets.UTF_8);
|
||||||
|
props.load(new StringReader(content));
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warn("Scheduler-Einstellungen konnten nicht geladen werden, "
|
||||||
|
+ "Standardwerte werden verwendet. Datei: {}", configFile, e);
|
||||||
|
return SchedulerSettings.defaults();
|
||||||
|
}
|
||||||
|
int intervalSeconds = parseInterval(props.getProperty(KEY_INTERVAL));
|
||||||
|
return new SchedulerSettings(intervalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt den Wert von {@code scheduler.interval.seconds} in die
|
||||||
|
* Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Alle übrigen Inhalte der Datei bleiben unverändert. Existiert der Key
|
||||||
|
* noch nicht, wird er am Ende der Datei ergänzt.
|
||||||
|
*
|
||||||
|
* @param seconds neues Intervall in Sekunden
|
||||||
|
* @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void saveIntervalSeconds(int seconds) throws SchedulerSettingsWriteException {
|
||||||
|
updateProperty(KEY_INTERVAL, String.valueOf(seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden: Parsen
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private int parseInterval(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return SchedulerSettings.DEFAULT_INTERVAL_SECONDS;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(raw.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return SchedulerSettings.DEFAULT_INTERVAL_SECONDS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden: format-erhaltende Schreiblogik
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void updateProperty(String key, String value) throws SchedulerSettingsWriteException {
|
||||||
|
try {
|
||||||
|
byte[] rawBytes = isLocked() ? readAllBytesViaChannel() : Files.readAllBytes(configFile);
|
||||||
|
String separator = detectLineSeparator(rawBytes);
|
||||||
|
String rawContent = new String(rawBytes, StandardCharsets.UTF_8);
|
||||||
|
List<String> lines = splitLines(rawContent, separator);
|
||||||
|
updateOrAppend(lines, key, value);
|
||||||
|
String newContent = String.join(separator, lines);
|
||||||
|
writeContent(newContent);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new SchedulerSettingsWriteException(
|
||||||
|
"Einstellung '" + key + "' konnte nicht in "
|
||||||
|
+ configFile + " geschrieben werden.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den vollständigen Dateiinhalt über den gemeinsamen {@link FileChannel}.
|
||||||
|
* Wird verwendet, wenn ein OS-Lock aktiv ist und {@link Files#readAllBytes} auf
|
||||||
|
* Windows die gesperrte Datei nicht öffnen kann.
|
||||||
|
*/
|
||||||
|
private byte[] readAllBytesViaChannel() throws IOException {
|
||||||
|
long fileSize = channel.size();
|
||||||
|
channel.position(0);
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream((int) Math.max(fileSize, 0));
|
||||||
|
ByteBuffer buf = ByteBuffer.allocate(8192);
|
||||||
|
while (channel.read(buf) != -1) {
|
||||||
|
buf.flip();
|
||||||
|
out.write(buf.array(), 0, buf.limit());
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt das Zeilentrennzeichen anhand der ersten vorkommenden Byte-Sequenz.
|
||||||
|
* Findet die Methode {@code \r\n}, wird {@code "\r\n"} zurückgegeben;
|
||||||
|
* andernfalls {@code "\n"}.
|
||||||
|
*/
|
||||||
|
private String detectLineSeparator(byte[] rawContent) {
|
||||||
|
for (int i = 0; i < rawContent.length - 1; i++) {
|
||||||
|
if (rawContent[i] == '\r' && rawContent[i + 1] == '\n') {
|
||||||
|
return "\r\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> splitLines(String content, String separator) {
|
||||||
|
String[] parts = content.split(Pattern.quote(separator), -1);
|
||||||
|
return new ArrayList<>(Arrays.asList(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht die erste Zeile, die den angegebenen Key definiert, und ersetzt den
|
||||||
|
* Wert. Wird keine passende Zeile gefunden, wird der Key am Ende der Datei
|
||||||
|
* eingefügt – unmittelbar vor einer abschließenden Leerzeile, sofern vorhanden.
|
||||||
|
*/
|
||||||
|
private void updateOrAppend(List<String> lines, String key, String value) {
|
||||||
|
for (int i = 0; i < lines.size(); i++) {
|
||||||
|
if (isKeyLine(lines.get(i), key)) {
|
||||||
|
lines.set(i, key + "=" + value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Key nicht gefunden: vor abschließender Leerzeile einfügen, sonst anhängen.
|
||||||
|
if (!lines.isEmpty() && lines.get(lines.size() - 1).isBlank()) {
|
||||||
|
lines.add(lines.size() - 1, key + "=" + value);
|
||||||
|
} else {
|
||||||
|
lines.add(key + "=" + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob die Zeile eine Property-Definition für genau den angegebenen Key
|
||||||
|
* darstellt. Kommentarzeilen (beginnend mit {@code #} oder {@code !}) werden
|
||||||
|
* immer als nicht-passend bewertet.
|
||||||
|
*/
|
||||||
|
private boolean isKeyLine(String line, String key) {
|
||||||
|
String trimmed = line.stripLeading();
|
||||||
|
if (trimmed.startsWith("#") || trimmed.startsWith("!")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!trimmed.startsWith(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int afterKey = key.length();
|
||||||
|
if (afterKey >= trimmed.length()) {
|
||||||
|
return false; // Zeile enthält nur den Schlüssel ohne Trennzeichen
|
||||||
|
}
|
||||||
|
char next = trimmed.charAt(afterKey);
|
||||||
|
return next == '=' || next == ':' || Character.isWhitespace(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt den Inhalt in die Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Ist der OS-Lock aktiv, wird über den gemeinsamen {@link FileChannel}
|
||||||
|
* geschrieben (Truncate → Position(0) → Write → Force). Ist kein Lock aktiv,
|
||||||
|
* wird eine temporäre Datei erzeugt und danach atomar verschoben.
|
||||||
|
*/
|
||||||
|
private void writeContent(String content) throws IOException {
|
||||||
|
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (isLocked()) {
|
||||||
|
channel.truncate(0);
|
||||||
|
channel.position(0);
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
|
channel.write(buffer);
|
||||||
|
}
|
||||||
|
channel.force(true);
|
||||||
|
} else {
|
||||||
|
Path tempFile = configFile.resolveSibling(configFile.getFileName() + ".tmp");
|
||||||
|
Files.writeString(tempFile, content, StandardCharsets.UTF_8,
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
Files.move(tempFile, configFile,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeChannelSilently() {
|
||||||
|
if (channel != null) {
|
||||||
|
try {
|
||||||
|
channel.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warn("Fehler beim Schließen des FileChannel für {}.", configFile, e);
|
||||||
|
}
|
||||||
|
channel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTrigger;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerConfig;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerPort;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementiert {@link SchedulerPort} auf Basis eines
|
||||||
|
* {@link ScheduledExecutorService} mit
|
||||||
|
* {@link ScheduledExecutorService#scheduleWithFixedDelay}.
|
||||||
|
* <p>
|
||||||
|
* Der erste Tick startet sofort (Initial Delay 0). Nachfolgende Ticks starten
|
||||||
|
* {@link SchedulerConfig#intervalSeconds()} Sekunden nach dem Ende des
|
||||||
|
* vorherigen Ticks. Der Verarbeitungsaufruf erfolgt synchron im
|
||||||
|
* Scheduler-Thread; der aufrufende Tick-Zyklus wartet also auf den Abschluss
|
||||||
|
* des Laufs, bevor der nächste Tick geplant wird.
|
||||||
|
* <p>
|
||||||
|
* Der Adapter delegiert ausschließlich an den injizierten {@link BatchRunTrigger}
|
||||||
|
* und trifft keine eigenen fachlichen Entscheidungen. Ergebnisse werden über
|
||||||
|
* den injizierten {@code Consumer<BatchRunTriggerResult>} zurückgemeldet.
|
||||||
|
* <p>
|
||||||
|
* Alle Ausnahmen innerhalb eines Ticks werden abgefangen und geloggt, damit
|
||||||
|
* der {@link ScheduledExecutorService} den Tick-Zyklus nicht still abbricht.
|
||||||
|
* <p>
|
||||||
|
* Instanzen dieser Klasse sind für den Einsatz in einem einzigen Steuerungs-Thread
|
||||||
|
* ausgelegt. {@link #startScheduler} und {@link #stopScheduler} müssen serialisiert
|
||||||
|
* aufgerufen werden.
|
||||||
|
*/
|
||||||
|
public class ScheduledExecutorServiceSchedulerAdapter implements SchedulerPort {
|
||||||
|
|
||||||
|
private static final Logger logger =
|
||||||
|
LogManager.getLogger(ScheduledExecutorServiceSchedulerAdapter.class);
|
||||||
|
|
||||||
|
private static final String SCHEDULER_THREAD_NAME = "pdf-umbenenner-scheduler";
|
||||||
|
|
||||||
|
private final Consumer<BatchRunTriggerResult> resultConsumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält den aktuell aktiven {@link BatchRunTrigger}. Package-private,
|
||||||
|
* damit Tests {@code onTick()} isoliert prüfen können, ohne den
|
||||||
|
* gesamten Lifecycle zu durchlaufen.
|
||||||
|
*/
|
||||||
|
final AtomicReference<BatchRunTrigger> currentTrigger = new AtomicReference<>();
|
||||||
|
|
||||||
|
private final AtomicReference<ScheduledExecutorService> executor = new AtomicReference<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Adapter.
|
||||||
|
*
|
||||||
|
* @param resultConsumer Empfänger für Tick-Ergebnisse; darf nicht {@code null} sein
|
||||||
|
*/
|
||||||
|
public ScheduledExecutorServiceSchedulerAdapter(Consumer<BatchRunTriggerResult> resultConsumer) {
|
||||||
|
this.resultConsumer = Objects.requireNonNull(resultConsumer,
|
||||||
|
"resultConsumer darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SchedulerPort
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet den periodischen Scheduler-Mechanismus.
|
||||||
|
* <p>
|
||||||
|
* Ist der Scheduler bereits aktiv, hat dieser Aufruf keine Wirkung (idempotent).
|
||||||
|
* Andernfalls wird ein Single-Thread-{@link ScheduledExecutorService} angelegt
|
||||||
|
* und mit {@code scheduleWithFixedDelay} und Initial-Delay 0 gestartet.
|
||||||
|
* Der erzeugte Thread heißt {@value SCHEDULER_THREAD_NAME} und ist kein Daemon-Thread.
|
||||||
|
*
|
||||||
|
* @param config Betriebskonfiguration; insbesondere das Intervall zwischen den Ticks
|
||||||
|
* @param trigger Auslöser, der bei jedem Tick synchron aufgerufen wird
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void startScheduler(SchedulerConfig config, BatchRunTrigger trigger) {
|
||||||
|
Objects.requireNonNull(config, "config darf nicht null sein");
|
||||||
|
Objects.requireNonNull(trigger, "trigger darf nicht null sein");
|
||||||
|
if (executor.get() != null) {
|
||||||
|
logger.debug("Scheduler ist bereits aktiv – Start-Aufruf wird ignoriert.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTrigger.set(trigger);
|
||||||
|
ThreadFactory threadFactory = runnable -> {
|
||||||
|
Thread t = new Thread(runnable, SCHEDULER_THREAD_NAME);
|
||||||
|
t.setDaemon(false);
|
||||||
|
t.setUncaughtExceptionHandler((thread, ex) ->
|
||||||
|
logger.error("Unbehandelte Ausnahme im Scheduler-Thread '{}'.",
|
||||||
|
thread.getName(), ex));
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
ScheduledExecutorService newExecutor =
|
||||||
|
Executors.newSingleThreadScheduledExecutor(threadFactory);
|
||||||
|
newExecutor.scheduleWithFixedDelay(
|
||||||
|
this::onTick,
|
||||||
|
0L,
|
||||||
|
config.intervalSeconds(),
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
executor.set(newExecutor);
|
||||||
|
logger.info("Scheduler gestartet. Intervall: {} Sekunden.", config.intervalSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt den periodischen Scheduler-Mechanismus.
|
||||||
|
* <p>
|
||||||
|
* Laufende Ticks werden nicht abgebrochen; es werden lediglich keine weiteren
|
||||||
|
* Ticks geplant. Ist der Scheduler bereits gestoppt, hat dieser Aufruf keine
|
||||||
|
* Wirkung (idempotent).
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void stopScheduler() {
|
||||||
|
ScheduledExecutorService localExecutor = executor.getAndSet(null);
|
||||||
|
if (localExecutor == null) {
|
||||||
|
logger.debug("Scheduler ist bereits gestoppt – Stop-Aufruf wird ignoriert.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentTrigger.set(null);
|
||||||
|
localExecutor.shutdown();
|
||||||
|
logger.info("Scheduler angehalten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Tick-Logik (package-private für Testbarkeit)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt einen Verarbeitungstick aus.
|
||||||
|
* <p>
|
||||||
|
* Holt den aktuellen {@link BatchRunTrigger}, ruft ihn synchron auf und
|
||||||
|
* leitet das Ergebnis an den {@link Consumer} weiter. Ist kein Trigger
|
||||||
|
* gesetzt, wird der Tick übersprungen. Alle {@link Exception}en werden
|
||||||
|
* abgefangen und auf ERROR geloggt, damit der
|
||||||
|
* {@link ScheduledExecutorService} den Tick-Zyklus nicht still abbricht.
|
||||||
|
* <p>
|
||||||
|
* Package-private, damit Unit-Tests diese Methode direkt aufrufen können.
|
||||||
|
*/
|
||||||
|
void onTick() {
|
||||||
|
BatchRunTrigger trigger = currentTrigger.get();
|
||||||
|
if (trigger == null) {
|
||||||
|
logger.warn("Scheduler-Tick ausgelöst, aber kein aktiver Trigger vorhanden. "
|
||||||
|
+ "Tick wird übersprungen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
BatchRunTriggerResult result = trigger.triggerRun();
|
||||||
|
resultConsumer.accept(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unbehandelte Ausnahme während des Scheduler-Ticks. "
|
||||||
|
+ "Der nächste Tick wird planmäßig ausgelöst.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platzhalter-Klasse, die sicherstellt, dass der Compiler das Modul
|
||||||
|
* nicht als leer behandelt.
|
||||||
|
* <p>
|
||||||
|
* Diese Klasse wird durch die echte Adapter-Implementierung ersetzt,
|
||||||
|
* sobald der Scheduler-Adapter implementiert wird.
|
||||||
|
*/
|
||||||
|
class SchedulerPlaceholder {
|
||||||
|
|
||||||
|
private SchedulerPlaceholder() {
|
||||||
|
// Nicht instanziierbar; wird durch echte Klassen ersetzt.
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Inbound-Adapter für den autonomen Scheduler-Betrieb.
|
||||||
|
* <p>
|
||||||
|
* Dieses Paket enthält den Adapter, der die periodische automatische
|
||||||
|
* Verarbeitung von PDF-Dateien ohne Benutzerinteraktion steuert.
|
||||||
|
* Der Adapter wird durch das Bootstrap-Modul verdrahtet und gestartet.
|
||||||
|
* Er ist ausschließlich vom Application-Modul abhängig und kennt weder
|
||||||
|
* JavaFX noch Bootstrap-interne Typen.
|
||||||
|
*/
|
||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
|
||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-Tests für {@link FileChannelConfigurationAccessAdapter}.
|
||||||
|
*/
|
||||||
|
class FileChannelConfigurationAccessAdapterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isLocked_returnsFalseBeforeAnyAcquire(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path config = createConfigFile(tempDir, "");
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
assertThat(adapter.isLocked()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquireLock_setsIsLockedTrue(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path config = createConfigFile(tempDir, "");
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
adapter.acquireLock();
|
||||||
|
try {
|
||||||
|
assertThat(adapter.isLocked()).isTrue();
|
||||||
|
} finally {
|
||||||
|
adapter.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releaseLock_setsIsLockedFalse(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path config = createConfigFile(tempDir, "");
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
adapter.acquireLock();
|
||||||
|
adapter.releaseLock();
|
||||||
|
|
||||||
|
assertThat(adapter.isLocked()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquireLock_calledTwice_isIdempotent(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path config = createConfigFile(tempDir, "");
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
adapter.acquireLock();
|
||||||
|
try {
|
||||||
|
assertThatCode(adapter::acquireLock).doesNotThrowAnyException();
|
||||||
|
assertThat(adapter.isLocked()).isTrue();
|
||||||
|
} finally {
|
||||||
|
adapter.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releaseLock_calledTwice_isIdempotent(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path config = createConfigFile(tempDir, "");
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
adapter.acquireLock();
|
||||||
|
adapter.releaseLock();
|
||||||
|
|
||||||
|
assertThatCode(adapter::releaseLock).doesNotThrowAnyException();
|
||||||
|
assertThat(adapter.isLocked()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releaseLock_withoutPriorAcquire_doesNotThrow(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path config = createConfigFile(tempDir, "");
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
assertThatCode(adapter::releaseLock).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acquireLock_throwsConfigurationFileLockException_whenFileDoesNotExist(
|
||||||
|
@TempDir Path tempDir) {
|
||||||
|
Path nonExistent = tempDir.resolve("missing.properties");
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(nonExistent);
|
||||||
|
|
||||||
|
assertThatThrownBy(adapter::acquireLock)
|
||||||
|
.isInstanceOf(ConfigurationFileLockException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadSettings_returnsDefaultsWhenKeysAreMissing(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path config = createConfigFile(tempDir, "source.folder=S:\\source\n");
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
SchedulerSettings settings = adapter.loadSettings();
|
||||||
|
|
||||||
|
assertThat(settings.intervalSeconds()).isEqualTo(SchedulerSettings.DEFAULT_INTERVAL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadSettings_returnsConfiguredValues(@TempDir Path tempDir) throws IOException {
|
||||||
|
String content = "scheduler.interval.seconds=300\n";
|
||||||
|
Path config = createConfigFile(tempDir, content);
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
SchedulerSettings settings = adapter.loadSettings();
|
||||||
|
|
||||||
|
assertThat(settings.intervalSeconds()).isEqualTo(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadSettings_returnsDefaultIntervalForNonNumericValue(@TempDir Path tempDir)
|
||||||
|
throws IOException {
|
||||||
|
String content = "scheduler.interval.seconds=not-a-number\n";
|
||||||
|
Path config = createConfigFile(tempDir, content);
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
SchedulerSettings settings = adapter.loadSettings();
|
||||||
|
|
||||||
|
assertThat(settings.intervalSeconds()).isEqualTo(SchedulerSettings.DEFAULT_INTERVAL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadSettings_returnsDefaultsWhenFileIsEmpty(@TempDir Path tempDir) throws IOException {
|
||||||
|
Path config = createConfigFile(tempDir, "");
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
SchedulerSettings settings = adapter.loadSettings();
|
||||||
|
|
||||||
|
assertThat(settings).isEqualTo(SchedulerSettings.defaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveIntervalSeconds_updatesExistingKeyAndPreservesOtherLines(@TempDir Path tempDir)
|
||||||
|
throws IOException {
|
||||||
|
String initial = "source.folder=/opt/source\nscheduler.interval.seconds=180\ntarget.folder=/opt/target\n";
|
||||||
|
Path config = createConfigFile(tempDir, initial);
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
adapter.saveIntervalSeconds(300);
|
||||||
|
|
||||||
|
Properties props = loadProperties(config);
|
||||||
|
assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("300");
|
||||||
|
assertThat(props.getProperty("source.folder")).isEqualTo("/opt/source");
|
||||||
|
assertThat(props.getProperty("target.folder")).isEqualTo("/opt/target");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveIntervalSeconds_appendsKeyWhenMissing(@TempDir Path tempDir) throws IOException {
|
||||||
|
String initial = "source.folder=/opt/source\n";
|
||||||
|
Path config = createConfigFile(tempDir, initial);
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
adapter.saveIntervalSeconds(240);
|
||||||
|
|
||||||
|
Properties props = loadProperties(config);
|
||||||
|
assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("240");
|
||||||
|
assertThat(props.getProperty("source.folder")).isEqualTo("/opt/source");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveIntervalSeconds_writesCorrectlyThroughChannelWhenLocked(@TempDir Path tempDir)
|
||||||
|
throws IOException {
|
||||||
|
String initial = "scheduler.interval.seconds=180\n";
|
||||||
|
Path config = createConfigFile(tempDir, initial);
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
adapter.acquireLock();
|
||||||
|
try {
|
||||||
|
adapter.saveIntervalSeconds(300);
|
||||||
|
} finally {
|
||||||
|
adapter.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
Properties props = loadProperties(config);
|
||||||
|
assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("300");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveIntervalSeconds_preservesCrlfLineEndings(@TempDir Path tempDir) throws IOException {
|
||||||
|
String initial = "scheduler.interval.seconds=180\r\nother.key=value\r\n";
|
||||||
|
Path config = createConfigFileBinary(tempDir, initial.getBytes(StandardCharsets.UTF_8));
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
adapter.saveIntervalSeconds(300);
|
||||||
|
|
||||||
|
byte[] resultBytes = Files.readAllBytes(config);
|
||||||
|
String result = new String(resultBytes, StandardCharsets.UTF_8);
|
||||||
|
assertThat(result).contains("scheduler.interval.seconds=300\r\n");
|
||||||
|
assertThat(result).contains("other.key=value\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveIntervalSeconds_preservesLfLineEndings(@TempDir Path tempDir) throws IOException {
|
||||||
|
String initial = "scheduler.interval.seconds=180\nother.key=value\n";
|
||||||
|
Path config = createConfigFile(tempDir, initial);
|
||||||
|
FileChannelConfigurationAccessAdapter adapter =
|
||||||
|
new FileChannelConfigurationAccessAdapter(config);
|
||||||
|
|
||||||
|
adapter.saveIntervalSeconds(300);
|
||||||
|
|
||||||
|
String result = Files.readString(config, StandardCharsets.UTF_8);
|
||||||
|
assertThat(result).contains("scheduler.interval.seconds=300\n");
|
||||||
|
assertThat(result).contains("other.key=value\n");
|
||||||
|
assertThat(result).doesNotContain("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path createConfigFile(Path tempDir, String content) throws IOException {
|
||||||
|
Path config = tempDir.resolve("test.properties");
|
||||||
|
Files.writeString(config, content, StandardCharsets.UTF_8);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path createConfigFileBinary(Path tempDir, byte[] bytes) throws IOException {
|
||||||
|
Path config = tempDir.resolve("test.properties");
|
||||||
|
Files.write(config, bytes);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Properties loadProperties(Path file) throws IOException {
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.load(new StringReader(Files.readString(file, StandardCharsets.UTF_8)));
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.BatchRunTriggerResult;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit- und Integrationstests für {@link ScheduledExecutorServiceSchedulerAdapter}.
|
||||||
|
* <p>
|
||||||
|
* Teststrategien:
|
||||||
|
* <ul>
|
||||||
|
* <li>Lifecycle-Tests (Start, Stop, Idempotenz) nutzen {@link CountDownLatch}
|
||||||
|
* für deterministische Synchronisation ohne {@code Thread.sleep}.</li>
|
||||||
|
* <li>Tick-Logik-Tests ({@code onTick}) rufen die package-private Methode
|
||||||
|
* direkt auf und setzen {@code currentTrigger} ohne Executor.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class ScheduledExecutorServiceSchedulerAdapterTest {
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Lifecycle: startScheduler
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startScheduler_triggersFirstTickImmediately() throws Exception {
|
||||||
|
List<BatchRunTriggerResult> results = new CopyOnWriteArrayList<>();
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(result -> {
|
||||||
|
results.add(result);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
SchedulerConfig config = new SchedulerConfig(3600);
|
||||||
|
adapter.startScheduler(config, () -> new BatchRunTriggerResult.SkippedBusy());
|
||||||
|
try {
|
||||||
|
assertThat(latch.await(5, TimeUnit.SECONDS))
|
||||||
|
.as("Erster Tick muss innerhalb von 5 Sekunden ausgelöst werden")
|
||||||
|
.isTrue();
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0)).isInstanceOf(BatchRunTriggerResult.SkippedBusy.class);
|
||||||
|
} finally {
|
||||||
|
adapter.stopScheduler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startScheduler_isIdempotent_secondCallDoesNotCreateSecondExecutor() throws Exception {
|
||||||
|
List<BatchRunTriggerResult> results = new CopyOnWriteArrayList<>();
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(result -> {
|
||||||
|
results.add(result);
|
||||||
|
latch.countDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
SchedulerConfig config = new SchedulerConfig(3600);
|
||||||
|
adapter.startScheduler(config, () -> new BatchRunTriggerResult.SkippedBusy());
|
||||||
|
adapter.startScheduler(config, () -> new BatchRunTriggerResult.SkippedBusy()); // no-op
|
||||||
|
|
||||||
|
try {
|
||||||
|
latch.await(5, TimeUnit.SECONDS);
|
||||||
|
// Kurze Wartezeit: ein zweiter Executor würde sofort einen zweiten Tick feuern
|
||||||
|
Thread.sleep(100);
|
||||||
|
assertThat(results)
|
||||||
|
.as("Nur ein Executor → genau ein sofortiger Tick mit Intervall 3600s")
|
||||||
|
.hasSize(1);
|
||||||
|
} finally {
|
||||||
|
adapter.stopScheduler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startScheduler_afterStop_canBeRestartedWithNewTrigger() throws Exception {
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(result -> {});
|
||||||
|
|
||||||
|
CountDownLatch firstLatch = new CountDownLatch(1);
|
||||||
|
CountDownLatch secondLatch = new CountDownLatch(1);
|
||||||
|
SchedulerConfig config = new SchedulerConfig(3600);
|
||||||
|
|
||||||
|
adapter.startScheduler(config, () -> {
|
||||||
|
firstLatch.countDown();
|
||||||
|
return new BatchRunTriggerResult.SkippedBusy();
|
||||||
|
});
|
||||||
|
firstLatch.await(5, TimeUnit.SECONDS);
|
||||||
|
adapter.stopScheduler();
|
||||||
|
|
||||||
|
List<BatchRunTriggerResult> secondResults = new CopyOnWriteArrayList<>();
|
||||||
|
adapter.startScheduler(config, () -> {
|
||||||
|
BatchRunTriggerResult r =
|
||||||
|
new BatchRunTriggerResult.Started(Instant.now(), RunSummary.noOp());
|
||||||
|
secondResults.add(r);
|
||||||
|
secondLatch.countDown();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertThat(secondLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
.as("Zweiter Start muss einen Tick auslösen")
|
||||||
|
.isTrue();
|
||||||
|
assertThat(secondResults.get(0)).isInstanceOf(BatchRunTriggerResult.Started.class);
|
||||||
|
} finally {
|
||||||
|
adapter.stopScheduler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Lifecycle: stopScheduler
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stopScheduler_withoutPriorStart_doesNotThrow() {
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(result -> {});
|
||||||
|
|
||||||
|
assertThatCode(adapter::stopScheduler).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stopScheduler_calledTwice_isIdempotent() throws Exception {
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(result -> {});
|
||||||
|
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
adapter.startScheduler(new SchedulerConfig(3600), () -> {
|
||||||
|
latch.countDown();
|
||||||
|
return new BatchRunTriggerResult.SkippedBusy();
|
||||||
|
});
|
||||||
|
latch.await(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
adapter.stopScheduler();
|
||||||
|
assertThatCode(adapter::stopScheduler)
|
||||||
|
.as("Zweiter Stop-Aufruf darf keine Ausnahme werfen")
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Tick-Logik: onTick (direkte Aufrufe, kein Executor)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onTick_whenTriggerIsNull_doesNotCallConsumer() {
|
||||||
|
List<BatchRunTriggerResult> results = new ArrayList<>();
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(results::add);
|
||||||
|
|
||||||
|
// Kein startScheduler → currentTrigger ist null
|
||||||
|
adapter.onTick();
|
||||||
|
|
||||||
|
assertThat(results).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onTick_whenTriggerReturnsSkippedBusy_passesResultToConsumer() {
|
||||||
|
List<BatchRunTriggerResult> results = new ArrayList<>();
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(results::add);
|
||||||
|
|
||||||
|
adapter.currentTrigger.set(() -> new BatchRunTriggerResult.SkippedBusy());
|
||||||
|
adapter.onTick();
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0)).isInstanceOf(BatchRunTriggerResult.SkippedBusy.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onTick_whenTriggerReturnsStarted_passesResultToConsumer() {
|
||||||
|
List<BatchRunTriggerResult> results = new ArrayList<>();
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(results::add);
|
||||||
|
|
||||||
|
Instant now = Instant.now();
|
||||||
|
RunSummary summary = new RunSummary(2, 1, 0);
|
||||||
|
adapter.currentTrigger.set(() -> new BatchRunTriggerResult.Started(now, summary));
|
||||||
|
adapter.onTick();
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
BatchRunTriggerResult.Started started =
|
||||||
|
(BatchRunTriggerResult.Started) results.get(0);
|
||||||
|
assertThat(started.endedAt()).isEqualTo(now);
|
||||||
|
assertThat(started.summary()).isEqualTo(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onTick_whenTriggerThrowsException_exceptionIsSwallowed() {
|
||||||
|
List<BatchRunTriggerResult> results = new ArrayList<>();
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(results::add);
|
||||||
|
|
||||||
|
adapter.currentTrigger.set(() -> {
|
||||||
|
throw new RuntimeException("Simulierter Trigger-Fehler");
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThatCode(adapter::onTick)
|
||||||
|
.as("Ausnahme im Trigger darf nicht aus onTick propagieren")
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
assertThat(results)
|
||||||
|
.as("Consumer darf nicht aufgerufen werden, wenn der Trigger wirft")
|
||||||
|
.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onTick_whenConsumerThrowsException_exceptionIsSwallowed() {
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(result -> {
|
||||||
|
throw new RuntimeException("Simulierter Consumer-Fehler");
|
||||||
|
});
|
||||||
|
|
||||||
|
adapter.currentTrigger.set(() -> new BatchRunTriggerResult.SkippedBusy());
|
||||||
|
|
||||||
|
assertThatCode(adapter::onTick)
|
||||||
|
.as("Ausnahme im Consumer darf nicht aus onTick propagieren")
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onTick_calledMultipleTimes_passesEachResultToConsumer() {
|
||||||
|
List<BatchRunTriggerResult> results = new ArrayList<>();
|
||||||
|
ScheduledExecutorServiceSchedulerAdapter adapter =
|
||||||
|
new ScheduledExecutorServiceSchedulerAdapter(results::add);
|
||||||
|
|
||||||
|
adapter.currentTrigger.set(() -> new BatchRunTriggerResult.SkippedBusy());
|
||||||
|
adapter.onTick();
|
||||||
|
adapter.onTick();
|
||||||
|
adapter.onTick();
|
||||||
|
|
||||||
|
assertThat(results).hasSize(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-6
@@ -95,6 +95,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
|
|||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public class OpenAiHttpAdapter implements AiInvocationPort {
|
public class OpenAiHttpAdapter implements AiInvocationPort {
|
||||||
|
private static final String NO_CHOICE_CONTENT_SENTINEL = "NO_CHOICE_CONTENT";
|
||||||
|
private static final String JSON_KEY_CONTENT = "content";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static final Logger LOG = LogManager.getLogger(OpenAiHttpAdapter.class);
|
private static final Logger LOG = LogManager.getLogger(OpenAiHttpAdapter.class);
|
||||||
|
|
||||||
@@ -248,20 +252,20 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
|
|||||||
JSONArray choices = json.optJSONArray("choices");
|
JSONArray choices = json.optJSONArray("choices");
|
||||||
if (choices == null || choices.isEmpty()) {
|
if (choices == null || choices.isEmpty()) {
|
||||||
LOG.warn("OpenAI response contained no choices");
|
LOG.warn("OpenAI response contained no choices");
|
||||||
return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT",
|
return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
|
||||||
"OpenAI response contained no choices");
|
"OpenAI response contained no choices");
|
||||||
}
|
}
|
||||||
JSONObject firstChoice = choices.getJSONObject(0);
|
JSONObject firstChoice = choices.getJSONObject(0);
|
||||||
JSONObject message = firstChoice.optJSONObject("message");
|
JSONObject message = firstChoice.optJSONObject("message");
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
LOG.warn("OpenAI response choice contained no message");
|
LOG.warn("OpenAI response choice contained no message");
|
||||||
return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT",
|
return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
|
||||||
"OpenAI response choice contained no message");
|
"OpenAI response choice contained no message");
|
||||||
}
|
}
|
||||||
String content = message.optString("content", null);
|
String content = message.optString(JSON_KEY_CONTENT, null);
|
||||||
if (content == null || content.isBlank()) {
|
if (content == null || content.isBlank()) {
|
||||||
LOG.warn("OpenAI response message.content is absent or blank");
|
LOG.warn("OpenAI response message.content is absent or blank");
|
||||||
return new AiInvocationTechnicalFailure(request, "NO_CHOICE_CONTENT",
|
return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
|
||||||
"OpenAI response message.content is absent or blank");
|
"OpenAI response message.content is absent or blank");
|
||||||
}
|
}
|
||||||
return new AiInvocationSuccess(request, new AiRawResponse(content));
|
return new AiInvocationSuccess(request, new AiRawResponse(content));
|
||||||
@@ -347,11 +351,11 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
|
|||||||
|
|
||||||
JSONObject systemMessage = new JSONObject();
|
JSONObject systemMessage = new JSONObject();
|
||||||
systemMessage.put("role", "system");
|
systemMessage.put("role", "system");
|
||||||
systemMessage.put("content", request.promptContent());
|
systemMessage.put(JSON_KEY_CONTENT, request.promptContent());
|
||||||
|
|
||||||
JSONObject userMessage = new JSONObject();
|
JSONObject userMessage = new JSONObject();
|
||||||
userMessage.put("role", "user");
|
userMessage.put("role", "user");
|
||||||
userMessage.put("content", request.documentText());
|
userMessage.put(JSON_KEY_CONTENT, request.documentText());
|
||||||
|
|
||||||
body.put("messages", new org.json.JSONArray()
|
body.put("messages", new org.json.JSONArray()
|
||||||
.put(systemMessage)
|
.put(systemMessage)
|
||||||
|
|||||||
+84
-19
@@ -4,22 +4,26 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
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 de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File-based implementation of {@link RunLockPort} that uses a lock file to prevent concurrent runs.
|
* Dateibasierte Implementierung von {@link RunLockPort}.
|
||||||
* <p>
|
* <p>
|
||||||
* Creates an exclusive lock file on acquire and deletes it on release.
|
* Verwendet eine Lock-Datei, um parallele Läufe zu verhindern.
|
||||||
* If the lock file already exists, {@link #acquire()} throws {@link RunLockUnavailableException}
|
* Beim Erwerb wird die Lock-Datei angelegt; bei der Freigabe wird sie gelöscht.
|
||||||
* to signal that another instance is already running.
|
* Existiert die Datei bereits, ist der Lock belegt.
|
||||||
* <p>
|
* <p>
|
||||||
* The lock file contains the PID of the acquiring process. Release is best-effort: a failure
|
* Die Lock-Datei enthält die PID des erwerbenden Prozesses.
|
||||||
* to delete the lock file is logged as a warning but does not throw.
|
* Die Freigabe ist best-effort: Ein Fehler beim Löschen wird als Warnung
|
||||||
|
* geloggt, wirft aber keine Ausnahme.
|
||||||
*/
|
*/
|
||||||
public class FilesystemRunLockPortAdapter implements RunLockPort {
|
public class FilesystemRunLockPortAdapter implements RunLockPort {
|
||||||
|
|
||||||
@@ -28,27 +32,31 @@ public class FilesystemRunLockPortAdapter implements RunLockPort {
|
|||||||
private final Path lockFile;
|
private final Path lockFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new FilesystemRunLockPortAdapter for the given lock file path.
|
* Erstellt einen neuen {@code FilesystemRunLockPortAdapter} für den
|
||||||
|
* angegebenen Lock-Datei-Pfad.
|
||||||
*
|
*
|
||||||
* @param lockFile path of the lock file to create on acquire and delete on release
|
* @param lockFile Pfad der Lock-Datei, die beim Erwerb angelegt und
|
||||||
|
* bei der Freigabe gelöscht wird
|
||||||
*/
|
*/
|
||||||
public FilesystemRunLockPortAdapter(Path lockFile) {
|
public FilesystemRunLockPortAdapter(Path lockFile) {
|
||||||
this.lockFile = lockFile;
|
this.lockFile = lockFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquires the run lock by creating the lock file.
|
* Erwirbt den Run-Lock durch Anlegen der Lock-Datei (blockierend).
|
||||||
* <p>
|
* <p>
|
||||||
* If the lock file already exists, throws {@link RunLockUnavailableException}.
|
* Existiert die Lock-Datei bereits, wird eine
|
||||||
* If the parent directory does not exist, it is created before attempting file creation.
|
* {@link RunLockUnavailableException} geworfen. Das übergeordnete
|
||||||
|
* Verzeichnis wird bei Bedarf angelegt.
|
||||||
*
|
*
|
||||||
* @throws RunLockUnavailableException if the lock file already exists or cannot be created
|
* @throws RunLockUnavailableException wenn die Lock-Datei bereits existiert
|
||||||
|
* oder nicht angelegt werden kann
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void acquire() {
|
public void acquire() {
|
||||||
if (Files.exists(lockFile)) {
|
if (Files.exists(lockFile)) {
|
||||||
throw new RunLockUnavailableException(
|
throw new RunLockUnavailableException(
|
||||||
"Run lock file already exists - another instance may be running: " + lockFile);
|
"Run-Lock-Datei existiert bereits – eine andere Instanz könnte laufen: " + lockFile);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Path parent = lockFile.getParent();
|
Path parent = lockFile.getParent();
|
||||||
@@ -57,26 +65,83 @@ public class FilesystemRunLockPortAdapter implements RunLockPort {
|
|||||||
}
|
}
|
||||||
long pid = ProcessHandle.current().pid();
|
long pid = ProcessHandle.current().pid();
|
||||||
Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW);
|
Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW);
|
||||||
LOG.debug("Run lock acquired: {} (PID {})", lockFile, pid);
|
LOG.debug("Run-Lock erworben: {} (PID {})", lockFile, pid);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RunLockUnavailableException("Failed to acquire run lock file: " + lockFile, e);
|
throw new RunLockUnavailableException("Run-Lock-Datei konnte nicht angelegt werden: " + lockFile, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Releases the run lock by deleting the lock file.
|
* Gibt den Run-Lock durch Löschen der Lock-Datei frei.
|
||||||
* <p>
|
* <p>
|
||||||
* If deletion fails, a warning is logged but no exception is thrown.
|
* Schlägt das Löschen fehl, wird eine Warnung geloggt; keine Ausnahme
|
||||||
|
* wird geworfen.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void release() {
|
public void release() {
|
||||||
try {
|
try {
|
||||||
boolean deleted = Files.deleteIfExists(lockFile);
|
boolean deleted = Files.deleteIfExists(lockFile);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
LOG.debug("Run lock released: {}", lockFile);
|
LOG.debug("Run-Lock freigegeben: {}", lockFile);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOG.warn("Failed to release run lock file: {} — manual cleanup may be required", lockFile, e);
|
LOG.warn("Run-Lock-Datei konnte nicht gelöscht werden: {} – manuelle Bereinigung erforderlich",
|
||||||
|
lockFile, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versucht nicht-blockierend, den Run-Lock zu erwerben.
|
||||||
|
* <p>
|
||||||
|
* Existiert die Lock-Datei bereits, wird sofort {@link Optional#empty()}
|
||||||
|
* zurückgegeben. Andernfalls wird die Datei atomar mit
|
||||||
|
* {@link StandardOpenOption#CREATE_NEW} angelegt. Schlägt das Anlegen
|
||||||
|
* aufgrund einer Race-Condition fehl (z.B. gleichzeitiger Erwerb durch
|
||||||
|
* eine andere Instanz), wird ebenfalls {@link Optional#empty()} zurückgegeben.
|
||||||
|
* <p>
|
||||||
|
* Das zurückgegebene {@link RunLockHandle} gibt den Lock idempotent frei.
|
||||||
|
*
|
||||||
|
* @return Handle mit dem erworbenen Lock, oder {@link Optional#empty()}
|
||||||
|
* wenn der Lock nicht verfügbar ist
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<RunLockHandle> tryAcquire() {
|
||||||
|
if (Files.exists(lockFile)) {
|
||||||
|
LOG.debug("Run-Lock nicht verfügbar (Datei existiert): {}", lockFile);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path parent = lockFile.getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
long pid = ProcessHandle.current().pid();
|
||||||
|
Files.writeString(lockFile, String.valueOf(pid), StandardOpenOption.CREATE_NEW);
|
||||||
|
LOG.debug("Run-Lock (tryAcquire) erworben: {} (PID {})", lockFile, pid);
|
||||||
|
return Optional.of(new FilesystemRunLockHandle());
|
||||||
|
} catch (IOException e) {
|
||||||
|
// CREATE_NEW schlägt mit FileAlreadyExistsException fehl wenn eine
|
||||||
|
// Race-Condition vorliegt – kein Fehler, sondern normaler Busy-Zustand
|
||||||
|
LOG.debug("Run-Lock (tryAcquire) nicht verfügbar: {} – {}", lockFile, e.getMessage());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle für einen über {@link #tryAcquire()} erworbenen Run-Lock.
|
||||||
|
* <p>
|
||||||
|
* Gibt den Lock idempotent frei. Mehrfaches Aufrufen von {@link #close()}
|
||||||
|
* hat nach dem ersten Aufruf keine Wirkung.
|
||||||
|
*/
|
||||||
|
private class FilesystemRunLockHandle implements RunLockHandle {
|
||||||
|
|
||||||
|
private final AtomicBoolean released = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (released.compareAndSet(false, true)) {
|
||||||
|
FilesystemRunLockPortAdapter.this.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-13
@@ -46,6 +46,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalog
|
|||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
|
public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
|
||||||
|
private static final String FAILURE_CODE_CONNECTION = "CONNECTION_FAILURE";
|
||||||
|
private static final String FAILURE_CODE_UNKNOWN = "UNKNOWN";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static final Logger LOG = LogManager.getLogger(ClaudeModelCatalogAdapter.class);
|
private static final Logger LOG = LogManager.getLogger(ClaudeModelCatalogAdapter.class);
|
||||||
|
|
||||||
@@ -133,28 +137,28 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
|
|||||||
|
|
||||||
} catch (java.net.http.HttpTimeoutException e) {
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
LOG.warn("Claude model catalogue: request timed out – {}", e.getMessage());
|
LOG.warn("Claude model catalogue: request timed out – {}", e.getMessage());
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
||||||
} catch (java.net.ConnectException e) {
|
} catch (java.net.ConnectException e) {
|
||||||
LOG.warn("Claude model catalogue: connection failed – {}", e.getMessage());
|
LOG.warn("Claude model catalogue: connection failed – {}", e.getMessage());
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
||||||
} catch (java.net.UnknownHostException e) {
|
} catch (java.net.UnknownHostException e) {
|
||||||
LOG.warn("Claude model catalogue: hostname not resolvable – {}", e.getMessage());
|
LOG.warn("Claude model catalogue: hostname not resolvable – {}", e.getMessage());
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Hostname nicht auflösbar: " + e.getMessage());
|
"Hostname nicht auflösbar: " + e.getMessage());
|
||||||
} catch (java.io.IOException e) {
|
} catch (java.io.IOException e) {
|
||||||
LOG.warn("Claude model catalogue: IO error – {}", e.getMessage());
|
LOG.warn("Claude model catalogue: IO error – {}", e.getMessage());
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
LOG.warn("Claude model catalogue: request interrupted");
|
LOG.warn("Claude model catalogue: request interrupted");
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Modellabruf wurde unterbrochen.");
|
"Modellabruf wurde unterbrochen.");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Claude model catalogue: unexpected error", e);
|
LOG.error("Claude model catalogue: unexpected error", e);
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
|
||||||
"Unerwarteter Fehler: " + e.getMessage());
|
"Unerwarteter Fehler: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +192,7 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
|
|||||||
|
|
||||||
if (status != 200) {
|
if (status != 200) {
|
||||||
LOG.warn("Claude model catalogue: unexpected HTTP status {}", status);
|
LOG.warn("Claude model catalogue: unexpected HTTP status {}", status);
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
|
||||||
"Unerwarteter HTTP-Status: " + status);
|
"Unerwarteter HTTP-Status: " + status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,24 +295,24 @@ public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
|
|||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
|
|
||||||
} catch (java.net.http.HttpTimeoutException e) {
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
||||||
} catch (java.net.ConnectException e) {
|
} catch (java.net.ConnectException e) {
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
||||||
} catch (java.net.UnknownHostException e) {
|
} catch (java.net.UnknownHostException e) {
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Hostname nicht auflösbar: " + e.getMessage());
|
"Hostname nicht auflösbar: " + e.getMessage());
|
||||||
} catch (java.io.IOException e) {
|
} catch (java.io.IOException e) {
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Modellabruf wurde unterbrochen.");
|
"Modellabruf wurde unterbrochen.");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Claude model catalogue: unexpected error", e);
|
LOG.error("Claude model catalogue: unexpected error", e);
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
|
||||||
"Unerwarteter Fehler: " + e.getMessage());
|
"Unerwarteter Fehler: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-13
@@ -46,6 +46,10 @@ import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalog
|
|||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
|
public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
|
||||||
|
private static final String FAILURE_CODE_CONNECTION = "CONNECTION_FAILURE";
|
||||||
|
private static final String FAILURE_CODE_UNKNOWN = "UNKNOWN";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static final Logger LOG = LogManager.getLogger(OpenAiCompatibleModelCatalogAdapter.class);
|
private static final Logger LOG = LogManager.getLogger(OpenAiCompatibleModelCatalogAdapter.class);
|
||||||
|
|
||||||
@@ -129,28 +133,28 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
|
|||||||
|
|
||||||
} catch (java.net.http.HttpTimeoutException e) {
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
LOG.warn("OpenAI-compatible model catalogue: request timed out – {}", e.getMessage());
|
LOG.warn("OpenAI-compatible model catalogue: request timed out – {}", e.getMessage());
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
||||||
} catch (java.net.ConnectException e) {
|
} catch (java.net.ConnectException e) {
|
||||||
LOG.warn("OpenAI-compatible model catalogue: connection failed – {}", e.getMessage());
|
LOG.warn("OpenAI-compatible model catalogue: connection failed – {}", e.getMessage());
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
||||||
} catch (java.net.UnknownHostException e) {
|
} catch (java.net.UnknownHostException e) {
|
||||||
LOG.warn("OpenAI-compatible model catalogue: hostname not resolvable – {}", e.getMessage());
|
LOG.warn("OpenAI-compatible model catalogue: hostname not resolvable – {}", e.getMessage());
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Hostname nicht auflösbar: " + e.getMessage());
|
"Hostname nicht auflösbar: " + e.getMessage());
|
||||||
} catch (java.io.IOException e) {
|
} catch (java.io.IOException e) {
|
||||||
LOG.warn("OpenAI-compatible model catalogue: IO error – {}", e.getMessage());
|
LOG.warn("OpenAI-compatible model catalogue: IO error – {}", e.getMessage());
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
LOG.warn("OpenAI-compatible model catalogue: request interrupted");
|
LOG.warn("OpenAI-compatible model catalogue: request interrupted");
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Modellabruf wurde unterbrochen.");
|
"Modellabruf wurde unterbrochen.");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
|
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
|
||||||
"Unerwarteter Fehler: " + e.getMessage());
|
"Unerwarteter Fehler: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +188,7 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
|
|||||||
|
|
||||||
if (status != 200) {
|
if (status != 200) {
|
||||||
LOG.warn("OpenAI-compatible model catalogue: unexpected HTTP status {}", status);
|
LOG.warn("OpenAI-compatible model catalogue: unexpected HTTP status {}", status);
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
|
||||||
"Unerwarteter HTTP-Status: " + status);
|
"Unerwarteter HTTP-Status: " + status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,24 +289,24 @@ public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
|
|||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
|
|
||||||
} catch (java.net.http.HttpTimeoutException e) {
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
|
||||||
} catch (java.net.ConnectException e) {
|
} catch (java.net.ConnectException e) {
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
|
||||||
} catch (java.net.UnknownHostException e) {
|
} catch (java.net.UnknownHostException e) {
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Hostname nicht auflösbar: " + e.getMessage());
|
"Hostname nicht auflösbar: " + e.getMessage());
|
||||||
} catch (java.io.IOException e) {
|
} catch (java.io.IOException e) {
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
"E/A-Fehler beim Modellabruf: " + e.getMessage());
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_CONNECTION,
|
||||||
"Modellabruf wurde unterbrochen.");
|
"Modellabruf wurde unterbrochen.");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
|
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
|
||||||
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
|
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, FAILURE_CODE_UNKNOWN,
|
||||||
"Unerwarteter Fehler: " + e.getMessage());
|
"Unerwarteter Fehler: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -48,6 +48,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
|
|||||||
* werden propagiert.
|
* werden propagiert.
|
||||||
*/
|
*/
|
||||||
public class FilesystemPromptPortAdapter implements PromptPort {
|
public class FilesystemPromptPortAdapter implements PromptPort {
|
||||||
|
private static final String SAVE_FAILED_LOG_MSG = "Prompt speichern fehlgeschlagen: {}";
|
||||||
|
|
||||||
|
|
||||||
private static final Logger LOG = LogManager.getLogger(FilesystemPromptPortAdapter.class);
|
private static final Logger LOG = LogManager.getLogger(FilesystemPromptPortAdapter.class);
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
|||||||
if (targetDir == null || !Files.isDirectory(targetDir)) {
|
if (targetDir == null || !Files.isDirectory(targetDir)) {
|
||||||
String message = "Zielordner der Prompt-Datei existiert nicht: "
|
String message = "Zielordner der Prompt-Datei existiert nicht: "
|
||||||
+ (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
|
+ (targetDir != null ? targetDir.toAbsolutePath() : "unbekannt");
|
||||||
LOG.warn("Prompt speichern fehlgeschlagen: {}", message);
|
LOG.warn(SAVE_FAILED_LOG_MSG, message);
|
||||||
return new PromptSaveResult.TargetDirectoryMissing(message);
|
return new PromptSaveResult.TargetDirectoryMissing(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +140,7 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
|||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
beräumeTempDatei(tempFile);
|
beräumeTempDatei(tempFile);
|
||||||
String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
|
String message = "Fehler beim Schreiben der temporären Prompt-Datei: " + e.getMessage();
|
||||||
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
|
LOG.warn(SAVE_FAILED_LOG_MSG, message, e);
|
||||||
return new PromptSaveResult.WriteFailed(message, e);
|
return new PromptSaveResult.WriteFailed(message, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ public class FilesystemPromptPortAdapter implements PromptPort {
|
|||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
beräumeTempDatei(tempFile);
|
beräumeTempDatei(tempFile);
|
||||||
String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
|
String message = "Fehler beim atomaren Verschieben der Prompt-Datei: " + e.getMessage();
|
||||||
LOG.warn("Prompt speichern fehlgeschlagen: {}", message, e);
|
LOG.warn(SAVE_FAILED_LOG_MSG, message, e);
|
||||||
return new PromptSaveResult.AtomicMoveFailed(message);
|
return new PromptSaveResult.AtomicMoveFailed(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -43,6 +43,8 @@ import de.gecheckt.pdf.umbenenner.application.validation.technicaltest.ResourceC
|
|||||||
* Ausnahmen an den Aufrufer weitergegeben.
|
* Ausnahmen an den Aufrufer weitergegeben.
|
||||||
*/
|
*/
|
||||||
public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
||||||
|
private static final String INVALID_PATH_PREFIX = "Ungültiger Pfad: ";
|
||||||
|
|
||||||
|
|
||||||
private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class);
|
private static final Logger LOG = LogManager.getLogger(FilesystemResourceCreationAdapter.class);
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
|||||||
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
|
public CorrectionOutcome createDirectory(CorrectionSuggestion.CreateDirectory suggestion) {
|
||||||
Path path = toPath(suggestion.path());
|
Path path = toPath(suggestion.path());
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
String msg = "Ungültiger Pfad: " + suggestion.path();
|
String msg = INVALID_PATH_PREFIX + suggestion.path();
|
||||||
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
|
LOG.warn("Ordner anlegen fehlgeschlagen: {}", msg);
|
||||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
}
|
}
|
||||||
@@ -114,7 +116,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
|||||||
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
|
public CorrectionOutcome createPromptFile(CorrectionSuggestion.CreatePromptFile suggestion) {
|
||||||
Path path = toPath(suggestion.path());
|
Path path = toPath(suggestion.path());
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
String msg = "Ungültiger Pfad: " + suggestion.path();
|
String msg = INVALID_PATH_PREFIX + suggestion.path();
|
||||||
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg);
|
LOG.warn("Prompt-Datei erzeugen fehlgeschlagen: {}", msg);
|
||||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
}
|
}
|
||||||
@@ -164,7 +166,7 @@ public class FilesystemResourceCreationAdapter implements ResourceCreationPort {
|
|||||||
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
public CorrectionOutcome prepareSqlitePath(CorrectionSuggestion.PrepareSqlitePath suggestion) {
|
||||||
Path path = toPath(suggestion.path());
|
Path path = toPath(suggestion.path());
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
String msg = "Ungültiger Pfad: " + suggestion.path();
|
String msg = INVALID_PATH_PREFIX + suggestion.path();
|
||||||
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", msg);
|
LOG.warn("SQLite-Pfad vorbereiten fehlgeschlagen: {}", msg);
|
||||||
return new CorrectionOutcome.Failed(suggestion, msg);
|
return new CorrectionOutcome.Failed(suggestion, msg);
|
||||||
}
|
}
|
||||||
|
|||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.sqlite.SQLiteConfig;
|
||||||
|
import org.sqlite.SQLiteDataSource;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-Implementierung des {@link DatabaseCreationPort}.
|
||||||
|
* <p>
|
||||||
|
* Erzeugt eine neue, leere SQLite-Datenbank gegen einen vom Aufrufer übergebenen
|
||||||
|
* temporären Zielpfad und führt eine vollständige Flyway-Migration auf den neuesten
|
||||||
|
* Schema-Stand aus. Anschließend wird ein Verbindungstest durchgeführt, der drei
|
||||||
|
* Aspekte verifiziert:
|
||||||
|
* <ol>
|
||||||
|
* <li>Eine SQLite-Verbindung kann erfolgreich geöffnet werden.</li>
|
||||||
|
* <li>Die Flyway-History-Tabelle (Standardname {@code flyway_schema_history}) ist
|
||||||
|
* vorhanden und enthält mindestens einen erfolgreichen Migrationseintrag.</li>
|
||||||
|
* <li>Eine einfache Leseabfrage gegen Schema-Metadaten
|
||||||
|
* ({@code sqlite_master}) liefert ohne Fehler.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* Im Fehlerfall wird die temporäre Datei zuverlässig wieder entfernt; aufrufende
|
||||||
|
* Komponenten erhalten ein klassifiziertes
|
||||||
|
* {@link DatabaseCreationPort.DatabaseCreationResult.Failure}-Ergebnis.
|
||||||
|
*
|
||||||
|
* <h2>Architekturgrenze</h2>
|
||||||
|
* <p>JDBC, SQLite-Konfiguration und Flyway-spezifische Typen verbleiben vollständig in
|
||||||
|
* dieser Klasse. Nach außen wird ausschließlich der versiegelte Port-Ergebnistyp
|
||||||
|
* herausgereicht.
|
||||||
|
*/
|
||||||
|
public class SqliteDatabaseCreationAdapter implements DatabaseCreationPort {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(SqliteDatabaseCreationAdapter.class);
|
||||||
|
private static final String FLYWAY_HISTORY_TABLE = "flyway_schema_history";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardkonstruktor.
|
||||||
|
*/
|
||||||
|
public SqliteDatabaseCreationAdapter() {
|
||||||
|
// keine Felder, kein Zustand
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine neue, leere SQLite-Datenbank an, migriert sie auf den neuesten Stand
|
||||||
|
* und führt einen Verbindungstest durch. Bei Fehlern wird die Temp-Datei entfernt.
|
||||||
|
*
|
||||||
|
* @param tempFile Pfad der zu erzeugenden temporären SQLite-Datei; darf nicht
|
||||||
|
* {@code null} sein und sollte vor dem Aufruf nicht existieren
|
||||||
|
* @return {@link DatabaseCreationResult.Success} bei Erfolg oder
|
||||||
|
* {@link DatabaseCreationResult.Failure} mit klassifizierter Phase
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public DatabaseCreationResult createAndInitialize(Path tempFile) {
|
||||||
|
if (tempFile == null) {
|
||||||
|
throw new NullPointerException("tempFile darf nicht null sein");
|
||||||
|
}
|
||||||
|
Path absoluteTemp = tempFile.toAbsolutePath().normalize();
|
||||||
|
LOG.info("Lege neue temporäre SQLite-Datenbank an: {}", absoluteTemp);
|
||||||
|
|
||||||
|
// Verhindern, dass eine versehentlich vorhandene Temp-Datei mitmigiert wird
|
||||||
|
try {
|
||||||
|
if (Files.exists(absoluteTemp)) {
|
||||||
|
Files.delete(absoluteTemp);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.error("Vorhandene temporäre Datei konnte nicht entfernt werden: {}",
|
||||||
|
absoluteTemp, e);
|
||||||
|
return new DatabaseCreationResult.Failure(
|
||||||
|
DatabaseCreationResult.Phase.FILE_CREATION,
|
||||||
|
"Vorhandene temporäre Datei konnte nicht entfernt werden: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
String jdbcUrl = buildJdbcUrl(absoluteTemp);
|
||||||
|
DataSource dataSource = createDataSource(jdbcUrl);
|
||||||
|
|
||||||
|
// Schema-Migration auf neuesten Stand
|
||||||
|
try {
|
||||||
|
Flyway flyway = Flyway.configure()
|
||||||
|
.dataSource(dataSource)
|
||||||
|
.locations("classpath:db/migration")
|
||||||
|
.connectRetries(0)
|
||||||
|
.load();
|
||||||
|
flyway.migrate();
|
||||||
|
LOG.info("Flyway-Migration auf neuesten Stand abgeschlossen für: {}", absoluteTemp);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("Flyway-Migration fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
|
||||||
|
cleanup(absoluteTemp);
|
||||||
|
return new DatabaseCreationResult.Failure(
|
||||||
|
DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
|
||||||
|
"Schema-Migration fehlgeschlagen: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbindungstest gegen die migrierte Temp-Datei
|
||||||
|
try {
|
||||||
|
verifyConnection(dataSource);
|
||||||
|
LOG.info("Verbindungstest gegen neue SQLite-Datenbank erfolgreich: {}", absoluteTemp);
|
||||||
|
} catch (SQLException | IllegalStateException e) {
|
||||||
|
LOG.error("Verbindungstest fehlgeschlagen für {}: {}", absoluteTemp, e.getMessage(), e);
|
||||||
|
cleanup(absoluteTemp);
|
||||||
|
return new DatabaseCreationResult.Failure(
|
||||||
|
DatabaseCreationResult.Phase.CONNECTION_TEST,
|
||||||
|
"Verbindungstest fehlgeschlagen: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DatabaseCreationResult.Success(absoluteTemp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifiziert die migrierte Datenbank durch drei aufeinander aufbauende Prüfungen.
|
||||||
|
*
|
||||||
|
* @param dataSource die DataSource gegen die Temp-Datei
|
||||||
|
* @throws SQLException bei JDBC-Fehlern
|
||||||
|
* @throws IllegalStateException wenn eine fachliche Erwartung (z. B. Flyway-History
|
||||||
|
* vorhanden, mind. ein erfolgreicher Eintrag) verletzt ist
|
||||||
|
*/
|
||||||
|
private void verifyConnection(DataSource dataSource) throws SQLException {
|
||||||
|
try (Connection conn = dataSource.getConnection()) {
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='"
|
||||||
|
+ FLYWAY_HISTORY_TABLE + "'")) {
|
||||||
|
if (!rs.next() || rs.getInt(1) != 1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Flyway-History-Tabelle fehlt nach der Migration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT count(*) FROM " + FLYWAY_HISTORY_TABLE + " WHERE success = 1")) {
|
||||||
|
if (!rs.next() || rs.getInt(1) < 1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Flyway-History enthält keinen erfolgreichen Migrationseintrag.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// einfache Leseabfrage gegen Schema-Metadaten
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'")) {
|
||||||
|
int tableCount = 0;
|
||||||
|
while (rs.next()) {
|
||||||
|
tableCount++;
|
||||||
|
}
|
||||||
|
if (tableCount < 1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Schema-Metadatenabfrage lieferte keine Tabellen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanup(Path tempFile) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warn("Temporäre SQLite-Datei konnte nach Fehler nicht entfernt werden: {} – {}",
|
||||||
|
tempFile, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die JDBC-URL für eine SQLite-Datei nach dem im Projekt etablierten Schema.
|
||||||
|
*
|
||||||
|
* @param dbFile absoluter Pfad der SQLite-Datei; darf nicht {@code null} sein
|
||||||
|
* @return die JDBC-URL in der Form {@code jdbc:sqlite:/pfad/zur/datei.db}
|
||||||
|
*/
|
||||||
|
private static String buildJdbcUrl(Path dbFile) {
|
||||||
|
return "jdbc:sqlite:" + dbFile.toAbsolutePath().toString().replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine SQLite-DataSource mit aktivierten Fremdschlüsseln.
|
||||||
|
*
|
||||||
|
* @param jdbcUrl die JDBC-URL der SQLite-Datei
|
||||||
|
* @return eine konfigurierte {@link DataSource}; nie {@code null}
|
||||||
|
*/
|
||||||
|
private static DataSource createDataSource(String jdbcUrl) {
|
||||||
|
SQLiteConfig config = new SQLiteConfig();
|
||||||
|
config.enforceForeignKeys(true);
|
||||||
|
SQLiteDataSource ds = new SQLiteDataSource(config);
|
||||||
|
ds.setUrl(jdbcUrl);
|
||||||
|
return ds;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-5
@@ -43,6 +43,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
|||||||
* application/domain type.
|
* application/domain type.
|
||||||
*/
|
*/
|
||||||
public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttemptRepository {
|
public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttemptRepository {
|
||||||
|
private static final String FINGERPRINT_NOT_NULL = "fingerprint must not be null";
|
||||||
|
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(SqliteProcessingAttemptRepositoryAdapter.class);
|
private static final Logger logger = LogManager.getLogger(SqliteProcessingAttemptRepositoryAdapter.class);
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
|
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
|
||||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
|
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
|
||||||
@@ -159,7 +161,8 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
|||||||
statement.setString(5, attempt.endedAt().toString());
|
statement.setString(5, attempt.endedAt().toString());
|
||||||
statement.setString(6, attempt.status().name());
|
statement.setString(6, attempt.status().name());
|
||||||
setNullableString(statement, 7, attempt.failureClass());
|
setNullableString(statement, 7, attempt.failureClass());
|
||||||
setNullableString(statement, 8, attempt.failureMessage());
|
// 1000-Zeichen-Grenze erzwingen; längere Meldungen werden mit „…" markiert
|
||||||
|
setNullableString(statement, 8, truncateFailureMessage(attempt.failureMessage()));
|
||||||
statement.setBoolean(9, attempt.retryable());
|
statement.setBoolean(9, attempt.retryable());
|
||||||
// AI provider identifier and AI traceability fields
|
// AI provider identifier and AI traceability fields
|
||||||
setNullableString(statement, 10, attempt.aiProvider());
|
setNullableString(statement, 10, attempt.aiProvider());
|
||||||
@@ -203,7 +206,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
|
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
@@ -254,7 +257,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
|
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
|
||||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
@@ -360,6 +363,27 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hilfsmethoden
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kürzt eine Fehlermeldung auf maximal 1000 Zeichen vor der Persistierung.
|
||||||
|
* Längere Meldungen werden mit „…" markiert.
|
||||||
|
*
|
||||||
|
* @param message die ursprüngliche Fehlermeldung; kann {@code null} sein
|
||||||
|
* @return die (ggf. gekürzte) Meldung oder {@code null}
|
||||||
|
*/
|
||||||
|
private static String truncateFailureMessage(String message) {
|
||||||
|
if (message == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (message.length() <= 1000) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return message.substring(0, 997) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// JDBC nullable helpers
|
// JDBC nullable helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -400,7 +424,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
|
public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
|
||||||
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
|
Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);
|
||||||
|
|
||||||
String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?";
|
String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?";
|
||||||
|
|
||||||
|
|||||||
+24
-20
@@ -9,7 +9,6 @@ import java.sql.Connection;
|
|||||||
import java.sql.DatabaseMetaData;
|
import java.sql.DatabaseMetaData;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -63,6 +62,11 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
|
|||||||
* Domain-/Application-Typen erscheinen keine JDBC- oder SQLite-Typen.
|
* Domain-/Application-Typen erscheinen keine JDBC- oder SQLite-Typen.
|
||||||
*/
|
*/
|
||||||
public class SqliteSchemaInitializationAdapter implements PersistenceSchemaInitializationPort {
|
public class SqliteSchemaInitializationAdapter implements PersistenceSchemaInitializationPort {
|
||||||
|
private static final String TABLE_DOCUMENT_RECORD = "document_record";
|
||||||
|
private static final String TABLE_PROCESSING_ATTEMPT = "processing_attempt";
|
||||||
|
private static final String COL_FINGERPRINT = "fingerprint";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class);
|
private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class);
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
|||||||
|
|
||||||
/** Alle erwarteten Spalten der Tabelle {@code document_record}. */
|
/** Alle erwarteten Spalten der Tabelle {@code document_record}. */
|
||||||
private static final Set<String> EXPECTED_COLUMNS_DOCUMENT_RECORD = Set.of(
|
private static final Set<String> EXPECTED_COLUMNS_DOCUMENT_RECORD = Set.of(
|
||||||
"id", "fingerprint", "last_known_source_locator", "last_known_source_file_name",
|
"id", COL_FINGERPRINT, "last_known_source_locator", "last_known_source_file_name",
|
||||||
"overall_status", "content_error_count", "transient_error_count",
|
"overall_status", "content_error_count", "transient_error_count",
|
||||||
"last_failure_instant", "last_success_instant", "created_at", "updated_at",
|
"last_failure_instant", "last_success_instant", "created_at", "updated_at",
|
||||||
"last_target_path", "last_target_file_name"
|
"last_target_path", "last_target_file_name"
|
||||||
@@ -80,7 +84,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
|||||||
|
|
||||||
/** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */
|
/** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */
|
||||||
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
|
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
|
||||||
"id", "fingerprint", "run_id", "attempt_number", "started_at", "ended_at",
|
"id", COL_FINGERPRINT, "run_id", "attempt_number", "started_at", "ended_at",
|
||||||
"status", "failure_class", "failure_message", "retryable",
|
"status", "failure_class", "failure_message", "retryable",
|
||||||
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
|
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
|
||||||
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
|
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
|
||||||
@@ -287,8 +291,8 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
|||||||
return DbState.FLYWAY_MANAGED;
|
return DbState.FLYWAY_MANAGED;
|
||||||
}
|
}
|
||||||
// "Leer" = keine Tabellen vorhanden (unabhängig von Dateigröße)
|
// "Leer" = keine Tabellen vorhanden (unabhängig von Dateigröße)
|
||||||
boolean hasFachlicheTabellen = tables.contains("document_record")
|
boolean hasFachlicheTabellen = tables.contains(TABLE_DOCUMENT_RECORD)
|
||||||
|| tables.contains("processing_attempt");
|
|| tables.contains(TABLE_PROCESSING_ATTEMPT);
|
||||||
if (hasFachlicheTabellen) {
|
if (hasFachlicheTabellen) {
|
||||||
return DbState.EXISTING_WITHOUT_FLYWAY;
|
return DbState.EXISTING_WITHOUT_FLYWAY;
|
||||||
}
|
}
|
||||||
@@ -321,25 +325,25 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
|||||||
|
|
||||||
// Tabellen prüfen
|
// Tabellen prüfen
|
||||||
Set<String> tabellen = readTableNames(meta);
|
Set<String> tabellen = readTableNames(meta);
|
||||||
if (!tabellen.contains("document_record")) {
|
if (!tabellen.contains(TABLE_DOCUMENT_RECORD)) {
|
||||||
fehler.add("Tabelle 'document_record' fehlt");
|
fehler.add("Tabelle 'document_record' fehlt");
|
||||||
}
|
}
|
||||||
if (!tabellen.contains("processing_attempt")) {
|
if (!tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
|
||||||
fehler.add("Tabelle 'processing_attempt' fehlt");
|
fehler.add("Tabelle 'processing_attempt' fehlt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spalten prüfen – nur wenn Tabellen vorhanden
|
// Spalten prüfen – nur wenn Tabellen vorhanden
|
||||||
if (tabellen.contains("document_record")) {
|
if (tabellen.contains(TABLE_DOCUMENT_RECORD)) {
|
||||||
pruefeSpaltenvollstaendigkeit(meta, "document_record",
|
pruefeSpaltenvollstaendigkeit(meta, TABLE_DOCUMENT_RECORD,
|
||||||
EXPECTED_COLUMNS_DOCUMENT_RECORD, fehler);
|
EXPECTED_COLUMNS_DOCUMENT_RECORD, fehler);
|
||||||
}
|
}
|
||||||
if (tabellen.contains("processing_attempt")) {
|
if (tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
|
||||||
pruefeSpaltenvollstaendigkeit(meta, "processing_attempt",
|
pruefeSpaltenvollstaendigkeit(meta, TABLE_PROCESSING_ATTEMPT,
|
||||||
EXPECTED_COLUMNS_PROCESSING_ATTEMPT, fehler);
|
EXPECTED_COLUMNS_PROCESSING_ATTEMPT, fehler);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indizes prüfen
|
// Indizes prüfen
|
||||||
if (tabellen.contains("document_record") && tabellen.contains("processing_attempt")) {
|
if (tabellen.contains(TABLE_DOCUMENT_RECORD) && tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
|
||||||
Set<String> vorhandeneIndizes = readIndexNames(meta);
|
Set<String> vorhandeneIndizes = readIndexNames(meta);
|
||||||
for (String erwartetIndex : EXPECTED_INDEXES) {
|
for (String erwartetIndex : EXPECTED_INDEXES) {
|
||||||
if (!vorhandeneIndizes.contains(erwartetIndex)) {
|
if (!vorhandeneIndizes.contains(erwartetIndex)) {
|
||||||
@@ -349,10 +353,10 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Constraints prüfen (soweit per Metadata prüfbar)
|
// Constraints prüfen (soweit per Metadata prüfbar)
|
||||||
if (tabellen.contains("document_record")) {
|
if (tabellen.contains(TABLE_DOCUMENT_RECORD)) {
|
||||||
pruefeUniqueConstraintAufFingerprint(conn, fehler);
|
pruefeUniqueConstraintAufFingerprint(conn, fehler);
|
||||||
}
|
}
|
||||||
if (tabellen.contains("processing_attempt")) {
|
if (tabellen.contains(TABLE_PROCESSING_ATTEMPT)) {
|
||||||
pruefeForeignKeyAufDocumentRecord(conn, fehler);
|
pruefeForeignKeyAufDocumentRecord(conn, fehler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,10 +404,10 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
|||||||
private void pruefeUniqueConstraintAufFingerprint(Connection conn,
|
private void pruefeUniqueConstraintAufFingerprint(Connection conn,
|
||||||
List<String> fehler) throws SQLException {
|
List<String> fehler) throws SQLException {
|
||||||
boolean uniqueGefunden = false;
|
boolean uniqueGefunden = false;
|
||||||
try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, "document_record", true, false)) {
|
try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, TABLE_DOCUMENT_RECORD, true, false)) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
String spalte = rs.getString("COLUMN_NAME");
|
String spalte = rs.getString("COLUMN_NAME");
|
||||||
if ("fingerprint".equalsIgnoreCase(spalte)) {
|
if (COL_FINGERPRINT.equalsIgnoreCase(spalte)) {
|
||||||
uniqueGefunden = true;
|
uniqueGefunden = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -425,12 +429,12 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
|||||||
private void pruefeForeignKeyAufDocumentRecord(Connection conn,
|
private void pruefeForeignKeyAufDocumentRecord(Connection conn,
|
||||||
List<String> fehler) throws SQLException {
|
List<String> fehler) throws SQLException {
|
||||||
boolean fkGefunden = false;
|
boolean fkGefunden = false;
|
||||||
try (ResultSet rs = conn.getMetaData().getImportedKeys(null, null, "processing_attempt")) {
|
try (ResultSet rs = conn.getMetaData().getImportedKeys(null, null, TABLE_PROCESSING_ATTEMPT)) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
String pkTabelle = rs.getString("PKTABLE_NAME");
|
String pkTabelle = rs.getString("PKTABLE_NAME");
|
||||||
String fkSpalte = rs.getString("FKCOLUMN_NAME");
|
String fkSpalte = rs.getString("FKCOLUMN_NAME");
|
||||||
if ("document_record".equalsIgnoreCase(pkTabelle)
|
if (TABLE_DOCUMENT_RECORD.equalsIgnoreCase(pkTabelle)
|
||||||
&& "fingerprint".equalsIgnoreCase(fkSpalte)) {
|
&& COL_FINGERPRINT.equalsIgnoreCase(fkSpalte)) {
|
||||||
fkGefunden = true;
|
fkGefunden = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -562,7 +566,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
|||||||
*/
|
*/
|
||||||
private static Set<String> readIndexNames(DatabaseMetaData meta) throws SQLException {
|
private static Set<String> readIndexNames(DatabaseMetaData meta) throws SQLException {
|
||||||
Set<String> names = new HashSet<>();
|
Set<String> names = new HashSet<>();
|
||||||
for (String tabelle : new String[]{"document_record", "processing_attempt"}) {
|
for (String tabelle : new String[]{TABLE_DOCUMENT_RECORD, TABLE_PROCESSING_ATTEMPT}) {
|
||||||
try (ResultSet rs = meta.getIndexInfo(null, null, tabelle, false, false)) {
|
try (ResultSet rs = meta.getIndexInfo(null, null, tabelle, false, false)) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
String indexName = rs.getString("INDEX_NAME");
|
String indexName = rs.getString("INDEX_NAME");
|
||||||
|
|||||||
+5
-3
@@ -24,6 +24,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
|
|||||||
* and processing attempt repositories.
|
* and processing attempt repositories.
|
||||||
*/
|
*/
|
||||||
public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||||
|
private static final String ROLLBACK_FAILED_MSG = "Rollback fehlgeschlagen: {}";
|
||||||
|
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(SqliteUnitOfWorkAdapter.class);
|
private static final Logger logger = LogManager.getLogger(SqliteUnitOfWorkAdapter.class);
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
connection.rollback();
|
connection.rollback();
|
||||||
logger.debug("Transaktion zurückgerollt (Dokumentfehler): {}", e.getMessage());
|
logger.debug("Transaktion zurückgerollt (Dokumentfehler): {}", e.getMessage());
|
||||||
} catch (SQLException rollbackEx) {
|
} catch (SQLException rollbackEx) {
|
||||||
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx);
|
logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
@@ -66,7 +68,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
connection.rollback();
|
connection.rollback();
|
||||||
logger.debug("Transaktion zurückgerollt (Laufzeitfehler): {}", e.getMessage());
|
logger.debug("Transaktion zurückgerollt (Laufzeitfehler): {}", e.getMessage());
|
||||||
} catch (SQLException rollbackEx) {
|
} catch (SQLException rollbackEx) {
|
||||||
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx);
|
logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
|
||||||
}
|
}
|
||||||
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
|
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
@@ -75,7 +77,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
|||||||
connection.rollback();
|
connection.rollback();
|
||||||
logger.debug("Transaktion zurückgerollt (SQL-Fehler): {}", e.getMessage());
|
logger.debug("Transaktion zurückgerollt (SQL-Fehler): {}", e.getMessage());
|
||||||
} catch (SQLException rollbackEx) {
|
} catch (SQLException rollbackEx) {
|
||||||
logger.error("Rollback fehlgeschlagen: {}", rollbackEx.getMessage(), rollbackEx);
|
logger.error(ROLLBACK_FAILED_MSG, rollbackEx.getMessage(), rollbackEx);
|
||||||
}
|
}
|
||||||
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
|
throw new DocumentPersistenceException("Transaktion fehlgeschlagen: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -214,10 +214,20 @@ class AnthropicClaudeAdapterIntegrationTest {
|
|||||||
* where log output is not relevant to the assertion.
|
* where log output is not relevant to the assertion.
|
||||||
*/
|
*/
|
||||||
private static class NoOpProcessingLogger implements ProcessingLogger {
|
private static class NoOpProcessingLogger implements ProcessingLogger {
|
||||||
@Override public void info(String message, Object... args) {}
|
@Override public void info(String message, Object... args) {
|
||||||
@Override public void debug(String message, Object... args) {}
|
// intentionally empty
|
||||||
@Override public void warn(String message, Object... args) {}
|
}
|
||||||
@Override public void error(String message, Object... args) {}
|
@Override public void debug(String message, Object... args) {
|
||||||
@Override public void debugSensitiveAiContent(String message, Object... args) {}
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void warn(String message, Object... args) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void error(String message, Object... args) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
|
@Override public void debugSensitiveAiContent(String message, Object... args) {
|
||||||
|
// intentionally empty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort.DatabaseCreationResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests für {@link SqliteDatabaseCreationAdapter}.
|
||||||
|
* <p>
|
||||||
|
* Prüft, dass eine neue, leere SQLite-Datei am übergebenen Temp-Pfad angelegt und
|
||||||
|
* vollständig per Flyway migriert wird, dass der Verbindungstest die Flyway-History
|
||||||
|
* verifiziert und dass Fehler im Verlauf zur Bereinigung der Temp-Datei führen.
|
||||||
|
*/
|
||||||
|
class SqliteDatabaseCreationAdapterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndInitialize_shouldRejectNullPath() {
|
||||||
|
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||||
|
assertThatThrownBy(() -> adapter.createAndInitialize(null))
|
||||||
|
.isInstanceOf(NullPointerException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndInitialize_shouldCreateAndMigrateNewSqliteFile(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path tempFile = tempDir.resolve("new-db.sqlite.tmp");
|
||||||
|
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||||
|
|
||||||
|
DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
|
||||||
|
assertThat(Files.exists(tempFile)).isTrue();
|
||||||
|
assertThat(Files.size(tempFile)).isGreaterThan(0);
|
||||||
|
|
||||||
|
// Schema verifizieren: Flyway-History und fachliche Tabellen müssen existieren
|
||||||
|
String jdbcUrl = "jdbc:sqlite:" + tempFile.toAbsolutePath().toString().replace('\\', '/');
|
||||||
|
try (Connection conn = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement stmt = conn.createStatement()) {
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE type='table' "
|
||||||
|
+ "AND name IN ('flyway_schema_history','document_record','processing_attempt')")) {
|
||||||
|
assertThat(rs.next()).isTrue();
|
||||||
|
assertThat(rs.getInt(1)).isEqualTo(3);
|
||||||
|
}
|
||||||
|
try (ResultSet rs = stmt.executeQuery(
|
||||||
|
"SELECT count(*) FROM flyway_schema_history WHERE success = 1")) {
|
||||||
|
assertThat(rs.next()).isTrue();
|
||||||
|
assertThat(rs.getInt(1)).isGreaterThanOrEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndInitialize_shouldOverwriteExistingTempFileBeforeMigration(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path tempFile = tempDir.resolve("existing.tmp");
|
||||||
|
Files.writeString(tempFile, "rest-zustand");
|
||||||
|
|
||||||
|
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||||
|
DatabaseCreationResult result = adapter.createAndInitialize(tempFile);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(DatabaseCreationResult.Success.class);
|
||||||
|
// Die Datei wurde durch eine leere SQLite-Datei ersetzt — der ursprüngliche Inhalt darf nicht mehr
|
||||||
|
// sichtbar sein.
|
||||||
|
assertThat(Files.size(tempFile)).isGreaterThan(0);
|
||||||
|
assertThat(Files.readString(tempFile, java.nio.charset.StandardCharsets.ISO_8859_1))
|
||||||
|
.doesNotContain("rest-zustand");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndInitialize_shouldFailAndCleanup_whenParentDirectoryDoesNotExist(@TempDir Path tempDir)
|
||||||
|
throws SQLException {
|
||||||
|
Path missingParent = tempDir.resolve("does-not-exist").resolve("child.tmp");
|
||||||
|
|
||||||
|
SqliteDatabaseCreationAdapter adapter = new SqliteDatabaseCreationAdapter();
|
||||||
|
DatabaseCreationResult result = adapter.createAndInitialize(missingParent);
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(DatabaseCreationResult.Failure.class);
|
||||||
|
DatabaseCreationResult.Failure failure = (DatabaseCreationResult.Failure) result;
|
||||||
|
assertThat(failure.phase())
|
||||||
|
.isIn(DatabaseCreationPort.DatabaseCreationResult.Phase.SCHEMA_MIGRATION,
|
||||||
|
DatabaseCreationPort.DatabaseCreationResult.Phase.CONNECTION_TEST,
|
||||||
|
DatabaseCreationPort.DatabaseCreationResult.Phase.FILE_CREATION);
|
||||||
|
assertThat(Files.exists(missingParent)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-6
@@ -1,6 +1,7 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -15,9 +16,6 @@ import java.util.Set;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import org.sqlite.SQLiteConfig;
|
|
||||||
import org.sqlite.SQLiteDataSource;
|
|
||||||
|
|
||||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,12 +193,14 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
String jdbcUrl = jdbcUrl(dir, "fall3.db");
|
String jdbcUrl = jdbcUrl(dir, "fall3.db");
|
||||||
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
|
||||||
|
|
||||||
|
assertThatCode(() -> {
|
||||||
// Erster Aufruf (Fall 1)
|
// Erster Aufruf (Fall 1)
|
||||||
adapter.initializeSchema();
|
adapter.initializeSchema();
|
||||||
// Zweiter Aufruf (Fall 3) – darf nicht werfen
|
// Zweiter Aufruf (Fall 3) – darf nicht werfen
|
||||||
adapter.initializeSchema();
|
adapter.initializeSchema();
|
||||||
// Dritter Aufruf (Fall 3) – ebenfalls idempotent
|
// Dritter Aufruf (Fall 3) – ebenfalls idempotent
|
||||||
adapter.initializeSchema();
|
adapter.initializeSchema();
|
||||||
|
}).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -256,7 +256,12 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
ds.setUrl(jdbcUrl);
|
ds.setUrl(jdbcUrl);
|
||||||
|
|
||||||
try (Connection conn = ds.getConnection()) {
|
try (Connection conn = ds.getConnection()) {
|
||||||
assertThatThrownBy(() -> {
|
assertThatThrownBy(() -> insertOrphanedProcessingAttempt(conn))
|
||||||
|
.isInstanceOf(SQLException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void insertOrphanedProcessingAttempt(Connection conn) throws SQLException {
|
||||||
try (var ps = conn.prepareStatement("""
|
try (var ps = conn.prepareStatement("""
|
||||||
INSERT INTO processing_attempt
|
INSERT INTO processing_attempt
|
||||||
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
|
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
|
||||||
@@ -265,8 +270,6 @@ class SqliteSchemaInitializationAdapterTest {
|
|||||||
""")) {
|
""")) {
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}).isInstanceOf(SQLException.class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
|
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -219,8 +220,8 @@ class FilesystemTargetFolderAdapterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void tryDeleteTargetFile_fileDoesNotExist_doesNotThrow() {
|
void tryDeleteTargetFile_fileDoesNotExist_doesNotThrow() {
|
||||||
// Must not throw even if the file is absent
|
assertThatCode(() -> adapter.tryDeleteTargetFile("nonexistent.pdf"))
|
||||||
adapter.tryDeleteTargetFile("nonexistent.pdf");
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound-Port zum Anlegen einer neuen, leeren SQLite-Datenbankdatei und zum Umstellen
|
||||||
|
* der aktiven Datenbankreferenz der Anwendung auf diese neue Datei.
|
||||||
|
* <p>
|
||||||
|
* Der Use-Case orchestriert den vollständigen, aus Anwendungssicht atomaren Ablauf:
|
||||||
|
* <ol>
|
||||||
|
* <li>Pfad-Sicherheitsprüfung: aktive DB darf nicht überschrieben werden;</li>
|
||||||
|
* <li>Erzeugung einer temporären SQLite-Datei im Zielverzeichnis;</li>
|
||||||
|
* <li>vollständige Schema-Migration auf den neuesten Stand;</li>
|
||||||
|
* <li>Verbindungstest gegen die migrierte Temp-Datei;</li>
|
||||||
|
* <li>atomarer Move auf den endgültigen Zielpfad
|
||||||
|
* ({@link java.nio.file.StandardCopyOption#ATOMIC_MOVE},
|
||||||
|
* {@link java.nio.file.StandardCopyOption#REPLACE_EXISTING});</li>
|
||||||
|
* <li>Umstellung der aktiven DB-Referenz über den
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort}.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* Schlägt ein Schritt fehl, bleibt die bisher aktive DB unverändert in Betrieb. Die
|
||||||
|
* temporäre Datei wird im Fehlerfall zuverlässig entfernt.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface CreateNewDatabaseUseCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine neue, leere SQLite-Datenbankdatei am übergebenen Zielpfad an und stellt
|
||||||
|
* die aktive Datenbankreferenz der Anwendung auf diese Datei um.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null}
|
||||||
|
* sein. Bei einer bereits existierenden Datei muss der Aufrufer
|
||||||
|
* vorab die Bestätigung des Benutzers eingeholt haben.
|
||||||
|
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie
|
||||||
|
* {@code null}
|
||||||
|
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
CreateNewDatabaseResult createNewDatabase(Path targetFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegeltes Ergebnis-Interface für {@link CreateNewDatabaseUseCase#createNewDatabase(Path)}.
|
||||||
|
*/
|
||||||
|
sealed interface CreateNewDatabaseResult
|
||||||
|
permits CreateNewDatabaseResult.Success,
|
||||||
|
CreateNewDatabaseResult.SameAsActiveDatabase,
|
||||||
|
CreateNewDatabaseResult.CreationFailed {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erfolgsfall. Die neue Datenbank wurde angelegt, migriert, getestet und ist
|
||||||
|
* jetzt die aktive Datenbank der Anwendung.
|
||||||
|
*
|
||||||
|
* @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; nie
|
||||||
|
* {@code null}
|
||||||
|
*/
|
||||||
|
record Success(Path targetFile) implements CreateNewDatabaseResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung.
|
||||||
|
*
|
||||||
|
* @param targetFile absoluter Pfad der neuen aktiven Datenbankdatei; darf
|
||||||
|
* nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
public Success {
|
||||||
|
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlerfall: Der gewählte Zielpfad ist die aktuell aktive Datenbankdatei.
|
||||||
|
* Diese darf nicht überschrieben werden. Die aktive DB bleibt unverändert.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer gewählte Zielpfad; nie {@code null}
|
||||||
|
*/
|
||||||
|
record SameAsActiveDatabase(Path targetFile) implements CreateNewDatabaseResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer gewählte Zielpfad; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code targetFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
public SameAsActiveDatabase {
|
||||||
|
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlerfall: Beim Anlegen, Migrieren, Testen oder beim atomaren Move ist ein
|
||||||
|
* technischer Fehler aufgetreten. Die aktive DB bleibt unverändert; eine evtl.
|
||||||
|
* angelegte Temp-Datei wurde entfernt.
|
||||||
|
*
|
||||||
|
* @param phase technische Phase, in der der Fehler auftrat; nie {@code null}
|
||||||
|
* @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
|
||||||
|
* @param cause ursächliche Ausnahme; kann {@code null} sein
|
||||||
|
*/
|
||||||
|
record CreationFailed(Phase phase, String message, Throwable cause)
|
||||||
|
implements CreateNewDatabaseResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
|
||||||
|
*
|
||||||
|
* @param phase technische Phase; darf nicht {@code null} sein
|
||||||
|
* @param message kurze, deutsche Fehlerbeschreibung; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @param cause ursächliche Ausnahme; kann {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code phase} oder {@code message}
|
||||||
|
* {@code null} ist
|
||||||
|
*/
|
||||||
|
public CreationFailed {
|
||||||
|
Objects.requireNonNull(phase, "phase darf nicht null sein");
|
||||||
|
Objects.requireNonNull(message, "message darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Technische Phase, in der ein Fehler aufgetreten ist.
|
||||||
|
*/
|
||||||
|
enum Phase {
|
||||||
|
/** Pfad-Sicherheitsprüfung (z. B. Auflösung über {@code toRealPath()}) ist fehlgeschlagen. */
|
||||||
|
PATH_RESOLUTION,
|
||||||
|
/** Anlage der temporären Datei ist fehlgeschlagen. */
|
||||||
|
FILE_CREATION,
|
||||||
|
/** Schema-Migration der temporären Datei ist fehlgeschlagen. */
|
||||||
|
SCHEMA_MIGRATION,
|
||||||
|
/** Verbindungstest gegen die migrierte Datei ist fehlgeschlagen. */
|
||||||
|
CONNECTION_TEST,
|
||||||
|
/**
|
||||||
|
* Atomarer Move der temporären Datei zum Zielpfad ist fehlgeschlagen –
|
||||||
|
* insbesondere wenn das Dateisystem die Kombination
|
||||||
|
* {@code ATOMIC_MOVE + REPLACE_EXISTING} nicht unterstützt. Es wird
|
||||||
|
* absichtlich kein nicht-atomarer Fallback durchgeführt.
|
||||||
|
*/
|
||||||
|
ATOMIC_MOVE,
|
||||||
|
/** Umstellung der aktiven DB-Referenz ist fehlgeschlagen. */
|
||||||
|
CONTEXT_SWITCH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.application.port.in;
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,9 +38,9 @@ public record HistoricalDocumentContext(
|
|||||||
* {@code lastFailureInstant} {@code null} sind
|
* {@code lastFailureInstant} {@code null} sind
|
||||||
*/
|
*/
|
||||||
public HistoricalDocumentContext {
|
public HistoricalDocumentContext {
|
||||||
lastTargetFileName = lastTargetFileName == null ? Optional.empty() : lastTargetFileName;
|
lastTargetFileName = Objects.requireNonNullElse(lastTargetFileName, Optional.empty());
|
||||||
lastSuccessInstant = lastSuccessInstant == null ? Optional.empty() : lastSuccessInstant;
|
lastSuccessInstant = Objects.requireNonNullElse(lastSuccessInstant, Optional.empty());
|
||||||
lastFailureInstant = lastFailureInstant == null ? Optional.empty() : lastFailureInstant;
|
lastFailureInstant = Objects.requireNonNullElse(lastFailureInstant, Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbound-Port zur Steuerung des automatischen Schedulers.
|
||||||
|
* <p>
|
||||||
|
* Dieser Use Case kapselt den vollständigen Scheduler-Lifecycle:
|
||||||
|
* Starten, Stoppen und Abfragen des aktuellen Zustands. Die Steuerung
|
||||||
|
* erfolgt ausschließlich über dieses Interface – GUI-Komponenten kennen
|
||||||
|
* weder den {@code SchedulerPort} noch Bootstrap-interne Typen.
|
||||||
|
* <p>
|
||||||
|
* Alle Operationen sind idempotent: Ein {@link #start()} auf einem
|
||||||
|
* bereits laufenden Scheduler ist ein No-op; ein {@link #stop()} auf
|
||||||
|
* einem bereits gestoppten Scheduler ebenso.
|
||||||
|
* <p>
|
||||||
|
* Implementierungen verwalten den Zustand threadsicher über eine
|
||||||
|
* {@code AtomicReference<SchedulerStatus>}.
|
||||||
|
*/
|
||||||
|
public interface SchedulerControlUseCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet den automatischen Scheduler.
|
||||||
|
* <p>
|
||||||
|
* Ist der Scheduler bereits aktiv ({@code state != STOPPED}), hat
|
||||||
|
* dieser Aufruf keine Wirkung. Andernfalls wird der Scheduler über
|
||||||
|
* folgende Sequenz gestartet:
|
||||||
|
* <ol>
|
||||||
|
* <li>Zustand auf {@code STARTING} setzen</li>
|
||||||
|
* <li>Exklusiven OS-Lock auf Konfigurationsdatei erwerben</li>
|
||||||
|
* <li>Scheduler-Adapter starten (erster Tick sofort)</li>
|
||||||
|
* <li>Zustand auf {@code RUNNING_IDLE} setzen</li>
|
||||||
|
* </ol>
|
||||||
|
* Schlägt ein Schritt fehl, wird ein vollständiger Rollback
|
||||||
|
* durchgeführt und der Zustand auf {@code STOPPED} zurückgesetzt.
|
||||||
|
*
|
||||||
|
* @throws SchedulerStartException wenn der Start fehlschlägt und
|
||||||
|
* kein Rollback möglich ist; enthält eine deutsche Meldung
|
||||||
|
* für die GUI-Anzeige
|
||||||
|
*/
|
||||||
|
void start() throws SchedulerStartException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt den automatischen Scheduler.
|
||||||
|
* <p>
|
||||||
|
* Ist der Scheduler bereits gestoppt, hat dieser Aufruf keine Wirkung.
|
||||||
|
* Läuft gerade ein Tick, wechselt der Zustand zu
|
||||||
|
* {@code STOPPING_BATCH_ACTIVE}; der laufende Batch wird regulär
|
||||||
|
* zu Ende geführt. Danach wird der OS-Lock freigegeben.
|
||||||
|
*/
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den aktuellen Scheduler-Zustand als unveränderlichen Snapshot zurück.
|
||||||
|
* <p>
|
||||||
|
* Der Snapshot kann von beliebigen Threads gelesen werden. Die GUI
|
||||||
|
* ruft diese Methode regelmäßig über eine zentrale Status-Refresh-Timeline
|
||||||
|
* auf und aktualisiert alle betroffenen Tabs entsprechend.
|
||||||
|
*
|
||||||
|
* @return aktueller Scheduler-Status; nie {@code null}
|
||||||
|
*/
|
||||||
|
SchedulerStatus getStatus();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das aktuell konfigurierte Ausführungsintervall in Sekunden zurück.
|
||||||
|
* <p>
|
||||||
|
* Wird vom Scheduler-Tab genutzt, um den Initialwert des Intervall-Feldes
|
||||||
|
* anzuzeigen. Der Wert entspricht dem beim Start der Anwendung geladenen
|
||||||
|
* Konfigurationswert (mindestens 30 Sekunden).
|
||||||
|
*
|
||||||
|
* @return Intervall in Sekunden; immer ≥ 30
|
||||||
|
*/
|
||||||
|
int getIntervalSeconds();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistiert das Ausführungsintervall in die Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Sicher nur aufzurufen wenn der Scheduler gestoppt ist. Der in-Memory-Wert
|
||||||
|
* wird nicht aktualisiert; der neue Wert wird beim nächsten Anwendungsstart
|
||||||
|
* gelesen.
|
||||||
|
* <p>
|
||||||
|
* Muss auf einem Hintergrund-Thread aufgerufen werden, da der Schreibvorgang
|
||||||
|
* den Konfigurations-Datei-Lock erwerben muss.
|
||||||
|
*
|
||||||
|
* @param seconds Intervall in Sekunden; sollte ≥ 30 sein
|
||||||
|
*/
|
||||||
|
void saveIntervalSeconds(int seconds);
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregierte Zähler über alle abgeschlossenen Ticks der laufenden bzw. zuletzt
|
||||||
|
* gelaufenen Scheduler-Sitzung.
|
||||||
|
* <p>
|
||||||
|
* Eine Sitzung beginnt mit dem nächsten erfolgreichen
|
||||||
|
* {@link SchedulerControlUseCase#start()} und endet mit dem zugehörigen
|
||||||
|
* {@link SchedulerControlUseCase#stop()}. Beim Start einer neuen Sitzung
|
||||||
|
* werden die Zähler auf null zurückgesetzt; nach dem Stopp bleiben sie
|
||||||
|
* eingefroren sichtbar, bis der Scheduler erneut gestartet wird.
|
||||||
|
* <p>
|
||||||
|
* Übersprungene Dokumente werden in dieser Sitzungsstatistik bewusst nicht
|
||||||
|
* gezählt, da sie für den Bediener keine neue Verarbeitungsleistung darstellen.
|
||||||
|
*
|
||||||
|
* @param successCount Summe aller erfolgreich verarbeiteten Dokumente seit
|
||||||
|
* Sitzungsstart; nie negativ
|
||||||
|
* @param failedCount Summe aller fehlgeschlagenen Dokumente seit Sitzungsstart
|
||||||
|
* (retryable und final zusammengefasst); nie negativ
|
||||||
|
*/
|
||||||
|
public record SchedulerSessionTotals(int successCount, int failedCount) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert, dass beide Zähler nicht negativ sind.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException wenn einer der Zähler kleiner als null ist
|
||||||
|
*/
|
||||||
|
public SchedulerSessionTotals {
|
||||||
|
if (successCount < 0 || failedCount < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"SchedulerSessionTotals counts must not be negative; was: "
|
||||||
|
+ successCount + "/" + failedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert ein neutrales Ausgangsobjekt mit beiden Zählern auf null.
|
||||||
|
*
|
||||||
|
* @return Sitzungstotal mit allen Zählern auf null; nie {@code null}
|
||||||
|
*/
|
||||||
|
public static SchedulerSessionTotals zero() {
|
||||||
|
return new SchedulerSessionTotals(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert ein neues Sitzungstotal, in dem die übergebenen Werte additiv
|
||||||
|
* aufgenommen wurden. Das aktuelle Objekt bleibt unverändert.
|
||||||
|
*
|
||||||
|
* @param additionalSuccess hinzuzurechnende Erfolgreich-Zahl; muss ≥ 0 sein
|
||||||
|
* @param additionalFailed hinzuzurechnende Fehler-Zahl; muss ≥ 0 sein
|
||||||
|
* @return aufaddiertes Sitzungstotal; nie {@code null}
|
||||||
|
*/
|
||||||
|
public SchedulerSessionTotals plus(int additionalSuccess, int additionalFailed) {
|
||||||
|
return new SchedulerSessionTotals(
|
||||||
|
successCount + additionalSuccess,
|
||||||
|
failedCount + additionalFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird geworfen, wenn der Start des automatischen Schedulers fehlschlägt.
|
||||||
|
* <p>
|
||||||
|
* Mögliche Ursachen sind: Fehler beim Erwerb des Konfigurations-Datei-Locks
|
||||||
|
* oder technische Fehler beim Starten des Scheduler-Adapters.
|
||||||
|
* <p>
|
||||||
|
* Diese Ausnahme ist ungeprüft (extends {@link RuntimeException}) und
|
||||||
|
* wird in der Callchain bis zum GUI-Layer weitergeleitet, der eine
|
||||||
|
* benutzerfreundliche deutsche Fehlermeldung anzeigt.
|
||||||
|
*/
|
||||||
|
public class SchedulerStartException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue {@code SchedulerStartException} mit der angegebenen Nachricht.
|
||||||
|
*
|
||||||
|
* @param message benutzerlesbare deutsche Fehlerbeschreibung
|
||||||
|
*/
|
||||||
|
public SchedulerStartException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue {@code SchedulerStartException} mit Nachricht und Ursache.
|
||||||
|
*
|
||||||
|
* @param message benutzerlesbare deutsche Fehlerbeschreibung
|
||||||
|
* @param cause technische Ursache
|
||||||
|
*/
|
||||||
|
public SchedulerStartException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustandsautomat des automatischen Schedulers.
|
||||||
|
* <p>
|
||||||
|
* Beschreibt den aktuellen Lebenszykluszustand des Schedulers und
|
||||||
|
* steuert, welche Aktionen (Starten, Stoppen, manuelle Läufe)
|
||||||
|
* in der GUI erlaubt sind.
|
||||||
|
*/
|
||||||
|
public enum SchedulerState {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduler ist gestoppt.
|
||||||
|
* <p>
|
||||||
|
* Manuelle Läufe sind erlaubt. Der Scheduler-Start-Button ist
|
||||||
|
* aktiv, sofern weitere Voraussetzungen erfüllt sind.
|
||||||
|
*/
|
||||||
|
STOPPED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduler befindet sich im Startvorgang.
|
||||||
|
* <p>
|
||||||
|
* Lock-Erwerb und initiale Einrichtung laufen. Manuelle Starts
|
||||||
|
* sind in diesem Übergangszustand deterministisch gesperrt.
|
||||||
|
*/
|
||||||
|
STARTING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduler läuft und wartet auf den nächsten Tick.
|
||||||
|
* <p>
|
||||||
|
* Kein Batch läuft gerade. Der Countdown bis zum nächsten Tick
|
||||||
|
* ist sichtbar. Manuelle Läufe sind gesperrt.
|
||||||
|
*/
|
||||||
|
RUNNING_IDLE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduler läuft und ein Tick verarbeitet gerade einen Batch.
|
||||||
|
* <p>
|
||||||
|
* Manuelle Läufe sind gesperrt. Der Stop-Button bleibt aktiv,
|
||||||
|
* bricht den laufenden Batch jedoch nicht ab.
|
||||||
|
*/
|
||||||
|
RUNNING_BATCH_ACTIVE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stopp wurde angefordert, aber der laufende Batch läuft noch zu Ende.
|
||||||
|
* <p>
|
||||||
|
* Nach Abschluss des Batches wechselt der Zustand zu {@link #STOPPED}.
|
||||||
|
* Der Status-Indikator zeigt „Gestoppt – aktueller Lauf läuft noch".
|
||||||
|
*/
|
||||||
|
STOPPING_BATCH_ACTIVE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der Scheduler in diesem Zustand als aktiv gilt.
|
||||||
|
* <p>
|
||||||
|
* Als aktiv gelten alle Zustände außer {@link #STOPPED}.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn der Scheduler nicht gestoppt ist
|
||||||
|
*/
|
||||||
|
public boolean isActive() {
|
||||||
|
return this != STOPPED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob in diesem Zustand ein Batch verarbeitet wird.
|
||||||
|
*
|
||||||
|
* @return {@code true} bei {@link #RUNNING_BATCH_ACTIVE} oder
|
||||||
|
* {@link #STOPPING_BATCH_ACTIVE}
|
||||||
|
*/
|
||||||
|
public boolean isBatchRunning() {
|
||||||
|
return this == RUNNING_BATCH_ACTIVE || this == STOPPING_BATCH_ACTIVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.in;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.RunSummary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unveränderlicher Snapshot des aktuellen Scheduler-Zustands.
|
||||||
|
* <p>
|
||||||
|
* Instanzen dieses Records sind threadsicher, da alle Felder final sind.
|
||||||
|
* Im {@code DefaultSchedulerControlUseCase} werden Snapshots atomar über
|
||||||
|
* eine {@code AtomicReference<SchedulerStatus>} ausgetauscht.
|
||||||
|
* <p>
|
||||||
|
* Die GUI liest diesen Snapshot regelmäßig über eine zentrale
|
||||||
|
* Status-Refresh-Timeline (1 Hz) und aktualisiert Scheduler-Tab,
|
||||||
|
* Batch-Tab und Konfig-Tab entsprechend.
|
||||||
|
*
|
||||||
|
* @param state aktueller Lebenszykluszustand des Schedulers
|
||||||
|
* @param lastRunEndedAt Endzeitpunkt des letzten abgeschlossenen Laufs;
|
||||||
|
* leer vor dem ersten Lauf
|
||||||
|
* @param lastRunSummary Zusammenfassung des letzten abgeschlossenen Laufs;
|
||||||
|
* leer vor dem ersten Lauf
|
||||||
|
* @param nextTickAt geplanter Zeitpunkt des nächsten Ticks;
|
||||||
|
* nur befüllt wenn {@code state == RUNNING_IDLE}
|
||||||
|
* @param lastError letzte aufgetretene deutsche Fehlermeldung;
|
||||||
|
* wird bei erfolgreichem Lauf gelöscht,
|
||||||
|
* bei {@code SkippedBusy} unverändert gelassen
|
||||||
|
* @param sessionTotals aggregierte Zähler seit dem letzten Sitzungsstart;
|
||||||
|
* leer vor dem allerersten {@code start()}, ab dem
|
||||||
|
* ersten erfolgreichen Start gefüllt und bei jedem
|
||||||
|
* weiteren Start auf null zurückgesetzt; nach dem
|
||||||
|
* Stopp bleibt der eingefrorene Endwert sichtbar
|
||||||
|
*/
|
||||||
|
public record SchedulerStatus(
|
||||||
|
SchedulerState state,
|
||||||
|
Optional<Instant> lastRunEndedAt,
|
||||||
|
Optional<RunSummary> lastRunSummary,
|
||||||
|
Optional<Instant> nextTickAt,
|
||||||
|
Optional<String> lastError,
|
||||||
|
Optional<SchedulerSessionTotals> sessionTotals
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert, dass Pflichtfelder und Optional-Felder nicht null sind.
|
||||||
|
*/
|
||||||
|
public SchedulerStatus {
|
||||||
|
if (state == null) {
|
||||||
|
throw new IllegalArgumentException("state darf nicht null sein");
|
||||||
|
}
|
||||||
|
Objects.requireNonNull(lastRunEndedAt, "lastRunEndedAt darf nicht null sein");
|
||||||
|
Objects.requireNonNull(lastRunSummary, "lastRunSummary darf nicht null sein");
|
||||||
|
Objects.requireNonNull(nextTickAt, "nextTickAt darf nicht null sein");
|
||||||
|
Objects.requireNonNull(lastError, "lastError darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den initialen Scheduler-Status beim Programmstart.
|
||||||
|
* <p>
|
||||||
|
* Zustand ist {@link SchedulerState#STOPPED} und alle optionalen Felder
|
||||||
|
* sind leer.
|
||||||
|
*
|
||||||
|
* @return initialer Scheduler-Status
|
||||||
|
*/
|
||||||
|
public static SchedulerStatus initial() {
|
||||||
|
return new SchedulerStatus(
|
||||||
|
SchedulerState.STOPPED,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port, der die zur Laufzeit aktive SQLite-Datenbankdatei der Anwendung kapselt.
|
||||||
|
* <p>
|
||||||
|
* Eigentümer der „aktiven DB-Referenz" zur Laufzeit. Der Port erlaubt es, die aktive
|
||||||
|
* Datenbank über einen In-Memory-Override umzustellen, ohne die Konfigurationsdatei
|
||||||
|
* (`.properties`) zu verändern. Die GUI nutzt diesen Mechanismus, damit nach dem Anlegen
|
||||||
|
* einer neuen Datenbank sofort sämtliche DB-Operationen (Verlauf, Reset, Löschen,
|
||||||
|
* Verarbeitungsläufe) gegen die neue Datei laufen, bevor der Benutzer die Konfiguration
|
||||||
|
* speichert.
|
||||||
|
* <p>
|
||||||
|
* <strong>Architekturgrenze:</strong> Der Port arbeitet ausschließlich mit
|
||||||
|
* {@link java.nio.file.Path} und kennt keine JDBC- oder SQLite-spezifischen Typen.
|
||||||
|
* Wie die Implementierung den Override technisch wirksam macht (z. B. durch Ersetzen
|
||||||
|
* der JDBC-URL beim Verdrahten neuer SQLite-Adapter), ist Adapter-Detail.
|
||||||
|
*/
|
||||||
|
public interface ActiveDatabaseContextPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt die aktive SQLite-Datenbankdatei der Anwendung um.
|
||||||
|
* <p>
|
||||||
|
* Nach dem Aufruf verwenden alle nachfolgenden DB-Operationen die übergebene Datei
|
||||||
|
* als aktive Datenbank, sofern keine andere Datei explizit übergeben wird.
|
||||||
|
*
|
||||||
|
* @param newDbFile absoluter Pfad der neuen aktiven Datenbankdatei; darf nicht
|
||||||
|
* {@code null} sein. Die Datei muss zum Zeitpunkt des Aufrufs
|
||||||
|
* existieren, ein gültiges SQLite-Schema enthalten und lesbar sein
|
||||||
|
* (Verbindung muss bereits durch den Aufrufer verifiziert worden sein).
|
||||||
|
* @throws NullPointerException wenn {@code newDbFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
void switchActiveDatabase(Path newDbFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den aktuell aktiven DB-Pfad als Override, sofern einer gesetzt wurde.
|
||||||
|
* <p>
|
||||||
|
* Solange kein Override gesetzt wurde, gilt die in der jeweiligen
|
||||||
|
* {@link de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration}
|
||||||
|
* konfigurierte Datenbankdatei. Erst nach dem ersten Aufruf von
|
||||||
|
* {@link #switchActiveDatabase(Path)} liefert diese Methode einen nicht-leeren Wert.
|
||||||
|
*
|
||||||
|
* @return das gesetzte Override (nicht-leer) oder {@link Optional#empty()}, wenn die
|
||||||
|
* konfigurierte Datenbank weiterhin verwendet werden soll; nie {@code null}
|
||||||
|
*/
|
||||||
|
Optional<Path> activeDatabaseOverride();
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Funktionales Interface zum Auslösen eines Verarbeitungslaufs.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface entkoppelt den Scheduler-Adapter von konkreten
|
||||||
|
* Bootstrap- oder GUI-Klassen. Das Bootstrap-Modul erzeugt beim Start
|
||||||
|
* eine Implementierung als Lambda und übergibt sie beim Starten des
|
||||||
|
* Schedulers an {@link SchedulerPort#startScheduler(SchedulerConfig, BatchRunTrigger)}.
|
||||||
|
* <p>
|
||||||
|
* <strong>Ausführungsmodell:</strong>
|
||||||
|
* <ul>
|
||||||
|
* <li>Der Aufruf ist synchron und blockiert bis zum Laufende.</li>
|
||||||
|
* <li>Ist der Run-Lock nicht verfügbar (anderer Lauf aktiv), kehrt die
|
||||||
|
* Methode sofort mit {@link BatchRunTriggerResult.SkippedBusy} zurück.</li>
|
||||||
|
* <li>Tritt ein technischer Fehler auf, liefert die Methode
|
||||||
|
* {@link BatchRunTriggerResult.Failed} mit deutschen Meldungen.
|
||||||
|
* Exceptions werden nicht propagiert; Stacktraces werden im Adapter
|
||||||
|
* geloggt und nicht im Result-Objekt transportiert.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface BatchRunTrigger {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst synchron einen Verarbeitungslauf aus.
|
||||||
|
* <p>
|
||||||
|
* Ist der RunLock nicht verfügbar, kehrt diese Methode sofort mit
|
||||||
|
* {@link BatchRunTriggerResult.SkippedBusy} zurück, ohne einen Lauf
|
||||||
|
* zu starten. Wird der Lauf gestartet, kehrt die Methode erst nach
|
||||||
|
* vollständigem Abschluss zurück.
|
||||||
|
*
|
||||||
|
* @return Ergebnis des Laufs; nie {@code null}
|
||||||
|
*/
|
||||||
|
BatchRunTriggerResult triggerRun();
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis eines über {@link BatchRunTrigger#triggerRun()} ausgelösten
|
||||||
|
* Verarbeitungslaufs.
|
||||||
|
* <p>
|
||||||
|
* Die sealed Hierarchie ermöglicht erschöpfendes Pattern-Matching:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link Started} – Lauf wurde gestartet und ist abgeschlossen.</li>
|
||||||
|
* <li>{@link SkippedBusy} – Lauf wurde übersprungen, weil bereits ein
|
||||||
|
* Lauf aktiv war.</li>
|
||||||
|
* <li>{@link Failed} – Lauf ist mit einem technischen Fehler beendet.</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* {@link Throwable}-Instanzen werden nicht im Result-Objekt transportiert.
|
||||||
|
* Stacktraces werden im Adapter geloggt; die GUI erhält ausschließlich
|
||||||
|
* benutzerfreundliche deutsche Meldungen.
|
||||||
|
*/
|
||||||
|
public sealed interface BatchRunTriggerResult
|
||||||
|
permits BatchRunTriggerResult.Started,
|
||||||
|
BatchRunTriggerResult.SkippedBusy,
|
||||||
|
BatchRunTriggerResult.Failed {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Lauf wurde erfolgreich gestartet und abgeschlossen.
|
||||||
|
* <p>
|
||||||
|
* Das Ergebnis enthält den Endzeitpunkt des Laufs sowie eine
|
||||||
|
* {@link RunSummary} mit den aggregierten Verarbeitungszählern.
|
||||||
|
* Ein No-op-Lauf (keine Kandidaten) ist ebenfalls {@code Started}
|
||||||
|
* und liefert eine {@link RunSummary} mit allen Zählern gleich null.
|
||||||
|
*
|
||||||
|
* @param endedAt Zeitpunkt, zu dem der Lauf abgeschlossen wurde
|
||||||
|
* @param summary Zusammenfassung der Verarbeitungsergebnisse
|
||||||
|
*/
|
||||||
|
record Started(Instant endedAt, RunSummary summary)
|
||||||
|
implements BatchRunTriggerResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert, dass Pflichtfelder nicht null sind.
|
||||||
|
*/
|
||||||
|
public Started {
|
||||||
|
if (endedAt == null) {
|
||||||
|
throw new IllegalArgumentException("endedAt darf nicht null sein");
|
||||||
|
}
|
||||||
|
if (summary == null) {
|
||||||
|
throw new IllegalArgumentException("summary darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Lauf wurde übersprungen, weil bereits ein anderer Lauf aktiv war.
|
||||||
|
* <p>
|
||||||
|
* {@link BatchRunTrigger#triggerRun()} kehrt sofort zurück, ohne
|
||||||
|
* einen neuen Lauf zu starten. Dieser Zustand ist kein Fehler.
|
||||||
|
*/
|
||||||
|
record SkippedBusy() implements BatchRunTriggerResult {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Lauf ist mit einem technischen Fehler beendet worden.
|
||||||
|
* <p>
|
||||||
|
* Enthält eine benutzerlesbare deutsche Meldung für die GUI-Anzeige
|
||||||
|
* sowie eine technische Meldung für Diagnose-Logging. Der zugehörige
|
||||||
|
* Stacktrace wurde bereits im Adapter auf ERROR geloggt und wird hier
|
||||||
|
* nicht transportiert.
|
||||||
|
*
|
||||||
|
* @param userMessage deutsche, GUI-taugliche Fehlermeldung für den Endanwender
|
||||||
|
* @param technicalMessage technische Detailmeldung für Diagnose und Logging
|
||||||
|
*/
|
||||||
|
record Failed(String userMessage, String technicalMessage)
|
||||||
|
implements BatchRunTriggerResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert, dass Pflichtfelder nicht null sind.
|
||||||
|
*/
|
||||||
|
public Failed {
|
||||||
|
if (userMessage == null) {
|
||||||
|
throw new IllegalArgumentException("userMessage darf nicht null sein");
|
||||||
|
}
|
||||||
|
if (technicalMessage == null) {
|
||||||
|
throw new IllegalArgumentException("technicalMessage darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird geworfen, wenn der exklusive OS-Lock auf die Konfigurationsdatei
|
||||||
|
* nicht erworben werden kann.
|
||||||
|
* <p>
|
||||||
|
* Typische Ursachen sind: ein externer Prozess hält die Datei bereits
|
||||||
|
* gesperrt, ein Netzlaufwerk reagiert nicht innerhalb der Deadline,
|
||||||
|
* oder die Datei ist nicht zugänglich.
|
||||||
|
* <p>
|
||||||
|
* Diese Ausnahme ist ungeprüft und wird vom Aufrufer
|
||||||
|
* ({@link ConfigurationFileLockPort#acquireLock()}) in eine
|
||||||
|
* benutzerfreundliche GUI-Meldung umgewandelt.
|
||||||
|
*/
|
||||||
|
public class ConfigurationFileLockException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue {@code ConfigurationFileLockException} mit der
|
||||||
|
* angegebenen Nachricht.
|
||||||
|
*
|
||||||
|
* @param message deutsche Fehlerbeschreibung
|
||||||
|
*/
|
||||||
|
public ConfigurationFileLockException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue {@code ConfigurationFileLockException} mit
|
||||||
|
* Nachricht und Ursache.
|
||||||
|
*
|
||||||
|
* @param message deutsche Fehlerbeschreibung
|
||||||
|
* @param cause technische Ursache
|
||||||
|
*/
|
||||||
|
public ConfigurationFileLockException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port für den exklusiven OS-Lock auf die Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Solange der Scheduler läuft oder ein Verarbeitungslauf aktiv ist, hält
|
||||||
|
* die Anwendung einen exklusiven Lock auf die {@code .properties}-Datei.
|
||||||
|
* Dieser Lock schützt vor konkurrierenden Schreibzugriffen durch externe
|
||||||
|
* Prozesse (z.B. Texteditoren).
|
||||||
|
* <p>
|
||||||
|
* Der Lock wird als bestmöglicher OS-Level-Schreibschutz betrachtet und
|
||||||
|
* erhebt keinen Anspruch darauf, alle externen Schreibstrategien zu
|
||||||
|
* verhindern (z.B. Delete-Rename durch manche Editoren).
|
||||||
|
* <p>
|
||||||
|
* Alle Operationen müssen im Worker-Thread ausgeführt werden,
|
||||||
|
* niemals auf dem JavaFX Application Thread.
|
||||||
|
*/
|
||||||
|
public interface ConfigurationFileLockPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erwirbt den exklusiven OS-Lock auf die Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Falls die Datei bereits durch einen anderen Prozess gesperrt ist,
|
||||||
|
* wird der Erwerb mit einer konfigurierbaren Deadline-Schleife
|
||||||
|
* versucht. Schlägt der Erwerb nach Ablauf der Deadline fehl,
|
||||||
|
* wird eine {@link ConfigurationFileLockException} geworfen.
|
||||||
|
* <p>
|
||||||
|
* Ist der Lock bereits durch diese Instanz gehalten, hat dieser
|
||||||
|
* Aufruf keine Wirkung.
|
||||||
|
*
|
||||||
|
* @throws ConfigurationFileLockException wenn der Lock nicht innerhalb
|
||||||
|
* der Deadline erworben werden kann
|
||||||
|
*/
|
||||||
|
void acquireLock() throws ConfigurationFileLockException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den exklusiven Lock frei.
|
||||||
|
* <p>
|
||||||
|
* Ist kein Lock aktiv, hat dieser Aufruf keine Wirkung (idempotent).
|
||||||
|
* Implementierungen dürfen bei der Freigabe keine geprüfte Ausnahme
|
||||||
|
* werfen; Fehler werden geloggt und still übergangen.
|
||||||
|
*/
|
||||||
|
void releaseLock();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der Lock aktuell von dieser Instanz gehalten wird.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn der Lock aktiv ist
|
||||||
|
*/
|
||||||
|
boolean isLocked();
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port zum Anlegen und Initialisieren einer neuen, leeren SQLite-Datenbankdatei
|
||||||
|
* gegen eine bereits vom Aufrufer reservierte temporäre Zieldatei.
|
||||||
|
* <p>
|
||||||
|
* Der Aufrufer (Use-Case) verantwortet die Lebensdauer der temporären Datei: er wählt den
|
||||||
|
* Pfad, übergibt ihn an diesen Port und führt nach Erfolg den atomaren Move auf den
|
||||||
|
* endgültigen Zieldateipfad selbst aus. Der Adapter beschränkt sich strikt auf:
|
||||||
|
* <ol>
|
||||||
|
* <li>Anlage und Migration der temporären SQLite-Datei auf den neuesten Schema-Stand
|
||||||
|
* (z. B. via Flyway {@code migrate()});</li>
|
||||||
|
* <li>technischer Verbindungstest gegen die migrierte Datei (Verbindung öffnen,
|
||||||
|
* Flyway-History prüfen, einfache Leseabfrage gegen Schema-Metadaten);</li>
|
||||||
|
* <li>Aufräumen der temporären Datei im Fehlerfall.</li>
|
||||||
|
* </ol>
|
||||||
|
* <p>
|
||||||
|
* <strong>Architekturgrenze:</strong> Provider- und SQLite-spezifische Details
|
||||||
|
* (JDBC-URL-Schema, DataSource-Konfiguration, Flyway-Konfiguration) bleiben
|
||||||
|
* ausschließlich im Adapter. Der Port arbeitet mit einem opaken {@link Path} und gibt
|
||||||
|
* ein versiegeltes Ergebnis zurück.
|
||||||
|
*/
|
||||||
|
public interface DatabaseCreationPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue, leere SQLite-Datenbankdatei am übergebenen temporären
|
||||||
|
* Zielpfad und führt eine vollständige Schema-Migration auf den neuesten Stand aus.
|
||||||
|
* <p>
|
||||||
|
* Bei Fehlern in einem der Teilschritte (Anlage, Migration, Verbindungstest) wird
|
||||||
|
* die temporäre Datei zuverlässig wieder entfernt; aufrufende Komponenten müssen
|
||||||
|
* diesen Aufräumschritt nicht selbst durchführen.
|
||||||
|
*
|
||||||
|
* @param tempFile Pfad der zu erstellenden temporären SQLite-Datei; darf nicht
|
||||||
|
* {@code null} sein. Die Datei darf vor dem Aufruf noch nicht
|
||||||
|
* existieren; das Elternverzeichnis muss existieren und schreibbar
|
||||||
|
* sein.
|
||||||
|
* @return ein versiegeltes Ergebnis: {@link DatabaseCreationResult.Success} bei Erfolg
|
||||||
|
* oder {@link DatabaseCreationResult.Failure} mit Fehlerklasse und Meldung
|
||||||
|
* im Fehlerfall; nie {@code null}.
|
||||||
|
* @throws NullPointerException wenn {@code tempFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
DatabaseCreationResult createAndInitialize(Path tempFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegeltes Ergebnis-Interface für {@link DatabaseCreationPort#createAndInitialize(Path)}.
|
||||||
|
*/
|
||||||
|
sealed interface DatabaseCreationResult
|
||||||
|
permits DatabaseCreationResult.Success, DatabaseCreationResult.Failure {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erfolgsergebnis. Die temporäre Datei wurde erfolgreich erstellt, migriert
|
||||||
|
* und durch den Verbindungstest verifiziert. Der Aufrufer kann sie nun atomar
|
||||||
|
* an den endgültigen Zielpfad verschieben.
|
||||||
|
*
|
||||||
|
* @param tempFile der temporäre, erfolgreich migrierte Pfad; nie {@code null}
|
||||||
|
*/
|
||||||
|
record Success(Path tempFile) implements DatabaseCreationResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung.
|
||||||
|
*
|
||||||
|
* @param tempFile der temporäre, erfolgreich migrierte Pfad; darf nicht
|
||||||
|
* {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code tempFile} {@code null} ist
|
||||||
|
*/
|
||||||
|
public Success {
|
||||||
|
Objects.requireNonNull(tempFile, "tempFile darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlerergebnis. Die temporäre Datei wurde – falls bereits angelegt – wieder
|
||||||
|
* entfernt; die aktive DB der Anwendung wurde nicht angetastet.
|
||||||
|
*
|
||||||
|
* @param phase die Phase, in der der Fehler auftrat; nie {@code null}
|
||||||
|
* @param message kurze, deutsche Fehlerbeschreibung; nie {@code null}
|
||||||
|
* @param cause ursächliche Ausnahme; kann {@code null} sein
|
||||||
|
*/
|
||||||
|
record Failure(Phase phase, String message, Throwable cause) implements DatabaseCreationResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konstruktor mit Pflichtprüfung der nicht-nullbaren Felder.
|
||||||
|
*
|
||||||
|
* @param phase die Phase, in der der Fehler auftrat; darf nicht {@code null} sein
|
||||||
|
* @param message kurze, deutsche Fehlerbeschreibung; darf nicht {@code null} sein
|
||||||
|
* @param cause ursächliche Ausnahme; kann {@code null} sein
|
||||||
|
* @throws NullPointerException wenn {@code phase} oder {@code message} {@code null} ist
|
||||||
|
*/
|
||||||
|
public Failure {
|
||||||
|
Objects.requireNonNull(phase, "phase darf nicht null sein");
|
||||||
|
Objects.requireNonNull(message, "message darf nicht null sein");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase der Erstellung einer neuen Datenbank, in der ein Fehler auftrat.
|
||||||
|
*/
|
||||||
|
enum Phase {
|
||||||
|
/** Die temporäre Datei konnte nicht erzeugt oder beschrieben werden. */
|
||||||
|
FILE_CREATION,
|
||||||
|
/** Die Schema-Migration (Flyway) ist fehlgeschlagen. */
|
||||||
|
SCHEMA_MIGRATION,
|
||||||
|
/** Der nachgelagerte Verbindungstest ist fehlgeschlagen. */
|
||||||
|
CONNECTION_TEST
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-3
@@ -18,6 +18,8 @@ public sealed interface PromptSaveResult
|
|||||||
PromptSaveResult.WriteFailed,
|
PromptSaveResult.WriteFailed,
|
||||||
PromptSaveResult.TargetDirectoryMissing,
|
PromptSaveResult.TargetDirectoryMissing,
|
||||||
PromptSaveResult.AtomicMoveFailed {
|
PromptSaveResult.AtomicMoveFailed {
|
||||||
|
String MESSAGE_NOT_NULL = "message must not be null";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Prompt-Datei wurde erfolgreich gespeichert.
|
* Die Prompt-Datei wurde erfolgreich gespeichert.
|
||||||
@@ -53,7 +55,7 @@ public sealed interface PromptSaveResult
|
|||||||
* @throws NullPointerException wenn {@code message} null ist
|
* @throws NullPointerException wenn {@code message} null ist
|
||||||
*/
|
*/
|
||||||
public WriteFailed {
|
public WriteFailed {
|
||||||
java.util.Objects.requireNonNull(message, "message must not be null");
|
java.util.Objects.requireNonNull(message, MESSAGE_NOT_NULL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ public sealed interface PromptSaveResult
|
|||||||
* @throws NullPointerException wenn {@code message} null ist
|
* @throws NullPointerException wenn {@code message} null ist
|
||||||
*/
|
*/
|
||||||
public TargetDirectoryMissing {
|
public TargetDirectoryMissing {
|
||||||
java.util.Objects.requireNonNull(message, "message must not be null");
|
java.util.Objects.requireNonNull(message, MESSAGE_NOT_NULL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ public sealed interface PromptSaveResult
|
|||||||
* @throws NullPointerException wenn {@code message} null ist
|
* @throws NullPointerException wenn {@code message} null ist
|
||||||
*/
|
*/
|
||||||
public AtomicMoveFailed {
|
public AtomicMoveFailed {
|
||||||
java.util.Objects.requireNonNull(message, "message must not be null");
|
java.util.Objects.requireNonNull(message, MESSAGE_NOT_NULL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle für einen erworbenen Run-Lock.
|
||||||
|
* <p>
|
||||||
|
* Dieses Interface ermöglicht die Nutzung des Run-Locks in einem
|
||||||
|
* try-with-resources-Block. Das Schließen des Handles gibt den Lock
|
||||||
|
* idempotent frei – mehrfaches Aufrufen von {@link #close()} ist sicher
|
||||||
|
* und hat nach dem ersten Aufruf keine Wirkung.
|
||||||
|
* <p>
|
||||||
|
* Instanzen dieses Typs werden ausschließlich von
|
||||||
|
* {@link RunLockPort#tryAcquire()} erzeugt und dürfen nicht
|
||||||
|
* weitergegeben oder gecacht werden.
|
||||||
|
*/
|
||||||
|
public interface RunLockHandle extends AutoCloseable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt den Run-Lock frei.
|
||||||
|
* <p>
|
||||||
|
* Diese Methode ist idempotent: Mehrfaches Aufrufen hat nach dem
|
||||||
|
* ersten Aufruf keine weitere Wirkung. Implementierungen dürfen
|
||||||
|
* keine geprüfte Ausnahme werfen.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
void close();
|
||||||
|
}
|
||||||
+53
-31
@@ -1,54 +1,76 @@
|
|||||||
package de.gecheckt.pdf.umbenenner.application.port.out;
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outbound port for exclusive run locking.
|
* Outbound-Port für den exklusiven Run-Lock.
|
||||||
* <p>
|
* <p>
|
||||||
* This port abstracts the mechanism for ensuring that only one instance of the PDF Umbenenner
|
* Stellt sicher, dass zu einem Zeitpunkt nur eine Instanz des PDF-Umbenenners
|
||||||
* is executing at any given time. The port defines the contract without prescribing the
|
* einen Verarbeitungslauf ausführt. Der Port abstrahiert den Mechanismus
|
||||||
* implementation (e.g., file-based locks, OS-level locks, distributed locks).
|
* (z.B. dateibasierter Lock) ohne eine konkrete Implementierung vorzuschreiben.
|
||||||
* <p>
|
* <p>
|
||||||
* Responsibilities:
|
* Verantwortlichkeiten:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Guarantee exclusive access to shared resources (SQLite database, target directory)</li>
|
* <li>Exklusiven Zugriff auf gemeinsame Ressourcen (SQLite, Zielordner) sicherstellen</li>
|
||||||
* <li>Prevent concurrent batch runs from overwriting each other's work or causing inconsistencies</li>
|
* <li>Parallele Läufe verhindern</li>
|
||||||
* <li>Allow controlled startup failure if another instance is already running</li>
|
* <li>Kontrollierten Startabbruch ermöglichen, wenn bereits eine Instanz läuft</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* Lock Lifecycle:
|
* Lock-Lifecycle (blockierender Pfad – headless, manueller GUI-Lauf und Scheduler-Tick):
|
||||||
* <ul>
|
* <ol>
|
||||||
* <li>Acquire the lock at batch startup (before any processing)</li>
|
* <li>Lock beim Laufstart erwerben ({@link #acquire()})</li>
|
||||||
* <li>Hold the lock for the entire batch run</li>
|
* <li>Lock für die gesamte Dauer des Laufs halten</li>
|
||||||
* <li>Release the lock cleanly at batch end (even on failure, if possible)</li>
|
* <li>Lock am Laufende freigeben ({@link #release()}), auch bei Fehler</li>
|
||||||
* </ul>
|
* </ol>
|
||||||
*
|
* Der Scheduler-Tick verwendet dieselbe blockierende Methode {@link #acquire()} wie
|
||||||
|
* der manuelle Laufpfad. {@link BatchRunProcessingUseCase#execute execute()} ruft
|
||||||
|
* {@link #acquire()} intern auf; das Ergebnis {@code LOCK_UNAVAILABLE} signalisiert
|
||||||
|
* dem Aufrufer, dass ein paralleler Lauf aktiv ist.
|
||||||
|
* {@link #tryAcquire()} ist für Aufrufer vorgesehen, die außerhalb des Use-Case
|
||||||
|
* einen schnellen, nicht-blockierenden Lock-Versuch benötigen.
|
||||||
*/
|
*/
|
||||||
public interface RunLockPort {
|
public interface RunLockPort {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquires an exclusive lock for the batch run.
|
* Erwirbt den exklusiven Run-Lock (blockierend).
|
||||||
* <p>
|
* <p>
|
||||||
* This method blocks or throws an exception if the lock cannot be acquired
|
* Wenn der Lock nicht erworben werden kann (z.B. weil eine andere
|
||||||
* (e.g., another instance already holds it). The behavior depends on the implementation.
|
* Instanz ihn bereits hält), wird eine {@link RunLockUnavailableException}
|
||||||
* <p>
|
* geworfen. Bei normalem Rücksprung hält der Aufrufer den Lock und muss
|
||||||
* If this method returns normally, the caller holds the lock and must ensure
|
* {@link #release()} in einem {@code finally}-Block aufrufen.
|
||||||
* {@link #release()} is called to free it, typically in a finally block.
|
|
||||||
*
|
*
|
||||||
* @throws RunLockUnavailableException if the lock cannot be acquired
|
* @throws RunLockUnavailableException wenn der Lock nicht erworben werden kann
|
||||||
* (e.g., another instance already holds it or system error prevents acquiring)
|
* @throws RuntimeException bei anderen kritischen Fehlern
|
||||||
* @throws RuntimeException for other critical lock-related failures
|
|
||||||
*/
|
*/
|
||||||
void acquire();
|
void acquire();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Releases the exclusive lock held by this batch run.
|
* Gibt den exklusiven Run-Lock frei.
|
||||||
* <p>
|
* <p>
|
||||||
* This method is called after batch processing completes (successfully or not)
|
* Wird nach Abschluss des Laufs (erfolgreich oder fehlerhaft) aufgerufen.
|
||||||
* to allow other instances to run.
|
* Implementierungen sollen keinen Fehler werfen, wenn der Lock bereits
|
||||||
* <p>
|
* freigegeben wurde oder gar nicht gehalten wird.
|
||||||
* Implementations should handle the case where release is called multiple times
|
|
||||||
* or when no lock is currently held, avoiding exceptions if possible.
|
|
||||||
*
|
*
|
||||||
* @throws RuntimeException if lock release fails critically
|
* @throws RuntimeException wenn die Freigabe kritisch fehlschlägt
|
||||||
*/
|
*/
|
||||||
void release();
|
void release();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versucht nicht-blockierend, den Run-Lock zu erwerben.
|
||||||
|
* <p>
|
||||||
|
* Gibt ein {@link RunLockHandle} zurück, wenn der Lock erfolgreich
|
||||||
|
* erworben wurde. Das Handle kann in einem try-with-resources-Block
|
||||||
|
* verwendet werden; {@link RunLockHandle#close()} gibt den Lock
|
||||||
|
* idempotent frei.
|
||||||
|
* <p>
|
||||||
|
* Ist der Lock bereits durch eine andere Instanz gehalten, wird sofort
|
||||||
|
* {@link Optional#empty()} zurückgegeben – ohne zu warten oder zu queuen.
|
||||||
|
* Diese Methode ist race-condition-sicher und frei von check-then-act-Mustern.
|
||||||
|
*
|
||||||
|
* @return Handle mit dem erworbenen Lock, oder {@link Optional#empty()}
|
||||||
|
* wenn der Lock nicht verfügbar ist
|
||||||
|
* @throws RuntimeException bei kritischen technischen Fehlern beim
|
||||||
|
* Lock-Versuch (nicht bei normaler Nicht-Verfügbarkeit)
|
||||||
|
*/
|
||||||
|
Optional<RunLockHandle> tryAcquire();
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zusammenfassung eines abgeschlossenen Verarbeitungslaufs.
|
||||||
|
* <p>
|
||||||
|
* Enthält die aggregierten Zähler für erfolgreich verarbeitete,
|
||||||
|
* fehlgeschlagene und übersprungene Dokumente. Ein No-op-Lauf –
|
||||||
|
* bei dem keine Kandidaten im Quellordner gefunden wurden – wird
|
||||||
|
* durch {@code RunSummary(0, 0, 0)} repräsentiert.
|
||||||
|
*
|
||||||
|
* @param successCount Anzahl der in diesem Lauf erfolgreich verarbeiteten Dokumente
|
||||||
|
* @param failedCount Anzahl der in diesem Lauf fehlgeschlagenen Dokumente
|
||||||
|
* (retryable und final zusammengefasst)
|
||||||
|
* @param skippedCount Anzahl der in diesem Lauf übersprungenen Dokumente
|
||||||
|
* (bereits verarbeitet oder dauerhaft fehlgeschlagen)
|
||||||
|
*/
|
||||||
|
public record RunSummary(int successCount, int failedCount, int skippedCount) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob dieser Lauf ein No-op-Lauf war, d.h. keine Dokumente
|
||||||
|
* verarbeitet, fehlgeschlagen oder übersprungen wurden.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn alle Zähler null sind
|
||||||
|
*/
|
||||||
|
public boolean isNoOp() {
|
||||||
|
return successCount == 0 && failedCount == 0 && skippedCount == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine {@code RunSummary} für einen No-op-Lauf.
|
||||||
|
*
|
||||||
|
* @return {@code RunSummary} mit allen Zählern gleich null
|
||||||
|
*/
|
||||||
|
public static RunSummary noOp() {
|
||||||
|
return new RunSummary(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Betriebskonfiguration für den automatischen Scheduler.
|
||||||
|
* <p>
|
||||||
|
* Enthält ausschließlich die für den Scheduler-Betrieb relevanten
|
||||||
|
* Laufzeitparameter. Die Konfiguration wird beim Start des Schedulers
|
||||||
|
* an {@link SchedulerPort#startScheduler(SchedulerConfig, BatchRunTrigger)}
|
||||||
|
* übergeben und bleibt für die Dauer des Scheduler-Betriebs unverändert.
|
||||||
|
*
|
||||||
|
* @param intervalSeconds Wartezeit in Sekunden zwischen dem Ende eines
|
||||||
|
* Laufs und dem Beginn des nächsten Ticks;
|
||||||
|
* muss mindestens 30 betragen
|
||||||
|
*/
|
||||||
|
public record SchedulerConfig(int intervalSeconds) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert, dass das Intervall mindestens 30 Sekunden beträgt.
|
||||||
|
*/
|
||||||
|
public SchedulerConfig {
|
||||||
|
if (intervalSeconds < 30) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Scheduler-Intervall muss mindestens 30 Sekunden betragen, war: " + intervalSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port zur Steuerung des technischen Scheduler-Mechanismus.
|
||||||
|
* <p>
|
||||||
|
* Kapselt die Infrastruktur für das periodische Polling (z.B. einen
|
||||||
|
* {@code ScheduledExecutorService}). Die fachliche Scheduler-Steuerung
|
||||||
|
* liegt im Use Case {@code SchedulerControlUseCase}; dieser Port
|
||||||
|
* delegiert ausschließlich den technischen Lifecycle-Start und -Stop.
|
||||||
|
* <p>
|
||||||
|
* Abhängigkeitsrichtung: Application → Adapter (hexagonal outbound).
|
||||||
|
*/
|
||||||
|
public interface SchedulerPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet den periodischen Scheduler-Mechanismus.
|
||||||
|
* <p>
|
||||||
|
* Der erste Tick startet sofort (Initial Delay 0). Nachfolgende Ticks
|
||||||
|
* starten jeweils {@link SchedulerConfig#intervalSeconds()} Sekunden
|
||||||
|
* nach dem Ende des vorigen Ticks ({@code scheduleWithFixedDelay}).
|
||||||
|
* <p>
|
||||||
|
* Der bereitgestellte {@link BatchRunTrigger} wird bei jedem Tick
|
||||||
|
* synchron aufgerufen. Der Scheduler-Adapter darf keine weiteren
|
||||||
|
* Entscheidungen treffen – er ruft {@code trigger.triggerRun()} auf
|
||||||
|
* und aktualisiert den Zustand anhand des Ergebnisses.
|
||||||
|
*
|
||||||
|
* @param config Betriebskonfiguration mit Intervall in Sekunden
|
||||||
|
* @param trigger Auslöser für den Verarbeitungslauf pro Tick
|
||||||
|
* @throws RuntimeException wenn der Scheduler-Mechanismus nicht
|
||||||
|
* gestartet werden kann
|
||||||
|
*/
|
||||||
|
void startScheduler(SchedulerConfig config, BatchRunTrigger trigger);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stoppt den periodischen Scheduler-Mechanismus.
|
||||||
|
* <p>
|
||||||
|
* Laufende Ticks werden nicht abgebrochen; es werden lediglich keine
|
||||||
|
* weiteren Ticks geplant. Ist der Scheduler bereits gestoppt, hat
|
||||||
|
* dieser Aufruf keine Wirkung (idempotent).
|
||||||
|
*/
|
||||||
|
void stopScheduler();
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistierte Scheduler-Einstellungen aus der {@code .properties}-Datei.
|
||||||
|
* <p>
|
||||||
|
* Dieses DTO repräsentiert die Scheduler-Property
|
||||||
|
* {@code scheduler.interval.seconds}, wie sie aus der Konfigurationsdatei
|
||||||
|
* gelesen wird. Es wird von {@link SchedulerSettingsPort#loadSettings()}
|
||||||
|
* zurückgegeben und dient als Eingabe für die Scheduler-Tab-Anzeige.
|
||||||
|
*
|
||||||
|
* @param intervalSeconds konfigurierte Wartezeit in Sekunden zwischen
|
||||||
|
* Läufen; entspricht dem gelesenen Rohwert
|
||||||
|
* ohne weitere Validierung
|
||||||
|
*/
|
||||||
|
public record SchedulerSettings(int intervalSeconds) {
|
||||||
|
|
||||||
|
/** Standardwert für {@code scheduler.interval.seconds}, wenn der Key fehlt oder leer ist. */
|
||||||
|
public static final int DEFAULT_INTERVAL_SECONDS = 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine {@code SchedulerSettings}-Instanz mit Standardwerten.
|
||||||
|
*
|
||||||
|
* @return Instanz mit {@code intervalSeconds=180}
|
||||||
|
*/
|
||||||
|
public static SchedulerSettings defaults() {
|
||||||
|
return new SchedulerSettings(DEFAULT_INTERVAL_SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound-Port zum Lesen und Schreiben der Scheduler-Einstellungen
|
||||||
|
* in der {@code .properties}-Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Schreiboperationen aktualisieren ausschließlich den Scheduler-Key
|
||||||
|
* {@code scheduler.interval.seconds}. Alle übrigen Zeilen, Kommentare
|
||||||
|
* und unbekannten Properties bleiben unverändert erhalten.
|
||||||
|
* <p>
|
||||||
|
* Schreibvorgänge sind atomar: Sie erfolgen über eine temporäre Datei,
|
||||||
|
* die erst nach vollständigem Schreiben an den Zielort verschoben wird.
|
||||||
|
* Bei einem Fehler bleibt die Originaldatei unverändert.
|
||||||
|
* <p>
|
||||||
|
* Die Implementierung teilt sich einen {@code FileChannel} mit dem
|
||||||
|
* {@link ConfigurationFileLockPort}-Adapter, damit Settings auch
|
||||||
|
* während eines aktiven OS-Locks geschrieben werden können.
|
||||||
|
*/
|
||||||
|
public interface SchedulerSettingsPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die aktuellen Scheduler-Einstellungen aus der
|
||||||
|
* Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Fehlt ein Key oder ist er leer, wird der jeweilige Standardwert
|
||||||
|
* zurückgegeben (siehe {@link SchedulerSettings#defaults()}).
|
||||||
|
* Ungültige Werte (z.B. nicht-numerisches Intervall) werden als
|
||||||
|
* Fehler in {@code SchedulerSettings} signalisiert und führen nicht
|
||||||
|
* zu einer Exception in diesem Port.
|
||||||
|
*
|
||||||
|
* @return gelesene Scheduler-Einstellungen; nie {@code null}
|
||||||
|
*/
|
||||||
|
SchedulerSettings loadSettings();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt den Wert von {@code scheduler.interval.seconds} in die
|
||||||
|
* Konfigurationsdatei.
|
||||||
|
* <p>
|
||||||
|
* Alle übrigen Inhalte der Datei bleiben unverändert.
|
||||||
|
*
|
||||||
|
* @param seconds neues Intervall in Sekunden; muss mindestens 30 betragen
|
||||||
|
* @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt
|
||||||
|
*/
|
||||||
|
void saveIntervalSeconds(int seconds) throws SchedulerSettingsWriteException;
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird geworfen, wenn das Schreiben der Scheduler-Einstellungen in die
|
||||||
|
* {@code .properties}-Datei fehlschlägt.
|
||||||
|
* <p>
|
||||||
|
* Der Schreibvorgang ist atomar (über eine temporäre Datei), sodass die
|
||||||
|
* Konfigurationsdatei bei einem Fehler nicht in einem korrupten Zustand
|
||||||
|
* hinterlassen wird. Diese Ausnahme signalisiert, dass weder der neue
|
||||||
|
* noch ein partieller Stand geschrieben wurde.
|
||||||
|
* <p>
|
||||||
|
* Ursachen können sein: fehlende Schreibrechte, Netzlaufwerksfehler,
|
||||||
|
* Festplatte voll oder ein aktiver exklusiver Lock durch einen anderen Prozess.
|
||||||
|
*/
|
||||||
|
public class SchedulerSettingsWriteException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue {@code SchedulerSettingsWriteException} mit der
|
||||||
|
* angegebenen Nachricht.
|
||||||
|
*
|
||||||
|
* @param message deutsche Fehlerbeschreibung
|
||||||
|
*/
|
||||||
|
public SchedulerSettingsWriteException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue {@code SchedulerSettingsWriteException} mit
|
||||||
|
* Nachricht und Ursache.
|
||||||
|
*
|
||||||
|
* @param message deutsche Fehlerbeschreibung
|
||||||
|
* @param cause technische Ursache
|
||||||
|
*/
|
||||||
|
public SchedulerSettingsWriteException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -32,7 +32,7 @@ public record EffectiveApiKeyDescriptor(
|
|||||||
*/
|
*/
|
||||||
public EffectiveApiKeyDescriptor {
|
public EffectiveApiKeyDescriptor {
|
||||||
Objects.requireNonNull(origin, "origin must not be null");
|
Objects.requireNonNull(origin, "origin must not be null");
|
||||||
envVarName = envVarName == null ? Optional.empty() : envVarName;
|
envVarName = Objects.requireNonNullElse(envVarName, Optional.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-2
@@ -45,7 +45,7 @@ public record ModelCatalogRequest(
|
|||||||
if (timeoutSeconds <= 0) {
|
if (timeoutSeconds <= 0) {
|
||||||
throw new IllegalArgumentException("timeoutSeconds must be positive, was: " + timeoutSeconds);
|
throw new IllegalArgumentException("timeoutSeconds must be positive, was: " + timeoutSeconds);
|
||||||
}
|
}
|
||||||
baseUrl = baseUrl == null ? Optional.empty() : baseUrl;
|
baseUrl = Objects.requireNonNullElse(baseUrl, Optional.empty());
|
||||||
apiKey = apiKey == null ? Optional.empty() : apiKey;
|
apiKey = Objects.requireNonNullElse(apiKey, Optional.empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -29,6 +29,8 @@ public sealed interface ModelCatalogResult
|
|||||||
ModelCatalogResult.EmptyList,
|
ModelCatalogResult.EmptyList,
|
||||||
ModelCatalogResult.IncompleteConfiguration,
|
ModelCatalogResult.IncompleteConfiguration,
|
||||||
ModelCatalogResult.TechnicalFailure {
|
ModelCatalogResult.TechnicalFailure {
|
||||||
|
String PROVIDER_ID_NOT_NULL = "providerIdentifier must not be null";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The provider returned a non-empty list of available model identifiers.
|
* The provider returned a non-empty list of available model identifiers.
|
||||||
@@ -55,7 +57,7 @@ public sealed interface ModelCatalogResult
|
|||||||
* @throws IllegalArgumentException if {@code models} is empty
|
* @throws IllegalArgumentException if {@code models} is empty
|
||||||
*/
|
*/
|
||||||
public Success {
|
public Success {
|
||||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
Objects.requireNonNull(providerIdentifier, PROVIDER_ID_NOT_NULL);
|
||||||
Objects.requireNonNull(models, "models must not be null");
|
Objects.requireNonNull(models, "models must not be null");
|
||||||
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
||||||
if (models.isEmpty()) {
|
if (models.isEmpty()) {
|
||||||
@@ -88,7 +90,7 @@ public sealed interface ModelCatalogResult
|
|||||||
* @throws NullPointerException if any parameter is {@code null}
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
*/
|
*/
|
||||||
public EmptyList {
|
public EmptyList {
|
||||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
Objects.requireNonNull(providerIdentifier, PROVIDER_ID_NOT_NULL);
|
||||||
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
Objects.requireNonNull(loadedAt, "loadedAt must not be null");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +120,7 @@ public sealed interface ModelCatalogResult
|
|||||||
* @throws NullPointerException if any parameter is {@code null}
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
*/
|
*/
|
||||||
public IncompleteConfiguration {
|
public IncompleteConfiguration {
|
||||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
Objects.requireNonNull(providerIdentifier, PROVIDER_ID_NOT_NULL);
|
||||||
Objects.requireNonNull(missingReason, "missingReason must not be null");
|
Objects.requireNonNull(missingReason, "missingReason must not be null");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +155,7 @@ public sealed interface ModelCatalogResult
|
|||||||
* @throws NullPointerException if any parameter is {@code null}
|
* @throws NullPointerException if any parameter is {@code null}
|
||||||
*/
|
*/
|
||||||
public TechnicalFailure {
|
public TechnicalFailure {
|
||||||
Objects.requireNonNull(providerIdentifier, "providerIdentifier must not be null");
|
Objects.requireNonNull(providerIdentifier, PROVIDER_ID_NOT_NULL);
|
||||||
Objects.requireNonNull(errorCategory, "errorCategory must not be null");
|
Objects.requireNonNull(errorCategory, "errorCategory must not be null");
|
||||||
Objects.requireNonNull(errorDetail, "errorDetail must not be null");
|
Objects.requireNonNull(errorDetail, "errorDetail must not be null");
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -38,6 +38,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
|
|||||||
* of {@link AiResponseValidator}.
|
* of {@link AiResponseValidator}.
|
||||||
*/
|
*/
|
||||||
public final class AiResponseParser {
|
public final class AiResponseParser {
|
||||||
|
private static final String JSON_KEY_TITLE = "title";
|
||||||
|
private static final String JSON_KEY_REASONING = "reasoning";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private AiResponseParser() {
|
private AiResponseParser() {
|
||||||
// Static utility – no instances
|
// Static utility – no instances
|
||||||
@@ -81,19 +85,19 @@ public final class AiResponseParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate mandatory field: title
|
// Validate mandatory field: title
|
||||||
if (!json.has("title") || json.isNull("title")) {
|
if (!json.has(JSON_KEY_TITLE) || json.isNull(JSON_KEY_TITLE)) {
|
||||||
return new AiResponseParsingFailure("MISSING_TITLE", "AI response missing mandatory field 'title'");
|
return new AiResponseParsingFailure("MISSING_TITLE", "AI response missing mandatory field 'title'");
|
||||||
}
|
}
|
||||||
String title = json.getString("title");
|
String title = json.getString(JSON_KEY_TITLE);
|
||||||
if (title.isBlank()) {
|
if (title.isBlank()) {
|
||||||
return new AiResponseParsingFailure("BLANK_TITLE", "AI response field 'title' is blank");
|
return new AiResponseParsingFailure("BLANK_TITLE", "AI response field 'title' is blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate mandatory field: reasoning
|
// Validate mandatory field: reasoning
|
||||||
if (!json.has("reasoning") || json.isNull("reasoning")) {
|
if (!json.has(JSON_KEY_REASONING) || json.isNull(JSON_KEY_REASONING)) {
|
||||||
return new AiResponseParsingFailure("MISSING_REASONING", "AI response missing mandatory field 'reasoning'");
|
return new AiResponseParsingFailure("MISSING_REASONING", "AI response missing mandatory field 'reasoning'");
|
||||||
}
|
}
|
||||||
String reasoning = json.getString("reasoning");
|
String reasoning = json.getString(JSON_KEY_REASONING);
|
||||||
|
|
||||||
// Optional field: date
|
// Optional field: date
|
||||||
String dateString = null;
|
String dateString = null;
|
||||||
|
|||||||
+15
-5
@@ -6,6 +6,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
|
|||||||
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
|
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
|
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
|
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
|
||||||
|
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailureReason;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
||||||
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
|
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
|
||||||
|
|
||||||
@@ -26,10 +27,14 @@ import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
|
|||||||
* <li><strong>Naming proposal ready:</strong> Status becomes
|
* <li><strong>Naming proposal ready:</strong> Status becomes
|
||||||
* {@link ProcessingStatus#PROPOSAL_READY}, counters unchanged,
|
* {@link ProcessingStatus#PROPOSAL_READY}, counters unchanged,
|
||||||
* {@code retryable=false}.</li>
|
* {@code retryable=false}.</li>
|
||||||
* <li><strong>Pre-check content error (first occurrence):</strong>
|
* <li><strong>Pre-check content error {@link PreCheckFailureReason#NO_USABLE_TEXT}:</strong>
|
||||||
|
* Status becomes {@link ProcessingStatus#FAILED_FINAL} immediately,
|
||||||
|
* content error counter incremented by 1, {@code retryable=false}.
|
||||||
|
* Image-only PDFs without OCR text will not yield usable text on retry.</li>
|
||||||
|
* <li><strong>Pre-check content error (other reason, first occurrence):</strong>
|
||||||
* Status becomes {@link ProcessingStatus#FAILED_RETRYABLE},
|
* Status becomes {@link ProcessingStatus#FAILED_RETRYABLE},
|
||||||
* content error counter incremented by 1, {@code retryable=true}.</li>
|
* content error counter incremented by 1, {@code retryable=true}.</li>
|
||||||
* <li><strong>Pre-check content error (second or later occurrence):</strong>
|
* <li><strong>Pre-check content error (other reason, second or later occurrence):</strong>
|
||||||
* Status becomes {@link ProcessingStatus#FAILED_FINAL},
|
* Status becomes {@link ProcessingStatus#FAILED_FINAL},
|
||||||
* content error counter incremented by 1, {@code retryable=false}.</li>
|
* content error counter incremented by 1, {@code retryable=false}.</li>
|
||||||
* <li><strong>AI functional failure (first occurrence):</strong>
|
* <li><strong>AI functional failure (first occurrence):</strong>
|
||||||
@@ -112,11 +117,16 @@ final class ProcessingOutcomeTransition {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case PreCheckFailed ignored2 -> {
|
case PreCheckFailed preCheckFailed -> {
|
||||||
// Deterministic content error from pre-check: apply the 1-retry rule
|
|
||||||
FailureCounters updatedCounters = existingCounters.withIncrementedContentErrorCount();
|
FailureCounters updatedCounters = existingCounters.withIncrementedContentErrorCount();
|
||||||
boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0;
|
|
||||||
|
|
||||||
|
if (preCheckFailed.failureReason() == PreCheckFailureReason.NO_USABLE_TEXT) {
|
||||||
|
// Image-only PDFs without OCR text will not change on retry.
|
||||||
|
yield new ProcessingOutcome(ProcessingStatus.FAILED_FINAL, updatedCounters, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other deterministic content errors: apply the 1-retry rule
|
||||||
|
boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0;
|
||||||
if (isFirstOccurrence) {
|
if (isFirstOccurrence) {
|
||||||
yield new ProcessingOutcome(ProcessingStatus.FAILED_RETRYABLE, updatedCounters, true);
|
yield new ProcessingOutcome(ProcessingStatus.FAILED_RETRYABLE, updatedCounters, true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.in.CreateNewDatabaseUseCase;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.ActiveDatabaseContextPort;
|
||||||
|
import de.gecheckt.pdf.umbenenner.application.port.out.DatabaseCreationPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardimplementierung des {@link CreateNewDatabaseUseCase}.
|
||||||
|
* <p>
|
||||||
|
* Orchestriert den vollständigen Anlage- und Wechselvorgang einer neuen, leeren
|
||||||
|
* SQLite-Datenbankdatei und delegiert die technischen Teilschritte an die Ports
|
||||||
|
* {@link DatabaseCreationPort} und {@link ActiveDatabaseContextPort}. Der Adapter
|
||||||
|
* darunter (z. B. SQLite/Flyway) bleibt für den Use-Case unsichtbar.
|
||||||
|
*
|
||||||
|
* <h2>Atomarität</h2>
|
||||||
|
* Aus Anwendungssicht ist der Wechsel atomar:
|
||||||
|
* <ul>
|
||||||
|
* <li>Bei einem Fehler in einem der Schritte wird die temporäre Datei zuverlässig
|
||||||
|
* entfernt; die aktive Datenbank bleibt unverändert in Betrieb.</li>
|
||||||
|
* <li>Erst nach erfolgreichem Verbindungstest wird die temporäre Datei via
|
||||||
|
* {@link StandardCopyOption#ATOMIC_MOVE} mit
|
||||||
|
* {@link StandardCopyOption#REPLACE_EXISTING} an den endgültigen Zielpfad
|
||||||
|
* verschoben. Bei nicht unterstützter Kombination wird der Vorgang mit
|
||||||
|
* klarer Fehlermeldung abgebrochen – kein stiller Fallback.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Pfad-Sicherheitsprüfung</h2>
|
||||||
|
* Aktive DB und Zielpfad werden über {@link Path#toRealPath(java.nio.file.LinkOption...)}
|
||||||
|
* normalisiert verglichen. Für noch nicht existierende Dateien wird das Elternverzeichnis
|
||||||
|
* real aufgelöst und der Dateiname normalisiert verglichen. Auf Windows erfolgt der
|
||||||
|
* Vergleich case-insensitive.
|
||||||
|
*/
|
||||||
|
public class DefaultCreateNewDatabaseUseCase implements CreateNewDatabaseUseCase {
|
||||||
|
|
||||||
|
private static final Logger LOG = LogManager.getLogger(DefaultCreateNewDatabaseUseCase.class);
|
||||||
|
private static final String OS_NAME = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
private final DatabaseCreationPort databaseCreationPort;
|
||||||
|
private final ActiveDatabaseContextPort activeDatabaseContextPort;
|
||||||
|
private final ActiveDatabasePathSupplier activeDatabasePathSupplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
|
||||||
|
* <p>
|
||||||
|
* Diese Indirektion erlaubt es dem Bootstrap, sowohl den
|
||||||
|
* {@link ActiveDatabaseContextPort}-Override als auch den Wert aus der geladenen
|
||||||
|
* Konfigurationsdatei zu berücksichtigen, ohne dass der Use-Case Konfigurationstypen
|
||||||
|
* kennen muss.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ActiveDatabasePathSupplier {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Pfad der aktuell aktiven SQLite-Datei.
|
||||||
|
*
|
||||||
|
* @return den absoluten Pfad der aktiven Datei; nie {@code null}
|
||||||
|
*/
|
||||||
|
Path get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Use-Case mit den drei erforderlichen Ports/Lieferanten.
|
||||||
|
*
|
||||||
|
* @param databaseCreationPort Port zum Anlegen und Initialisieren der Temp-Datei;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param activeDatabaseContextPort Port zum Umstellen der aktiven DB-Referenz;
|
||||||
|
* darf nicht {@code null} sein
|
||||||
|
* @param activeDatabasePathSupplier Lieferant für den Pfad der aktuell aktiven
|
||||||
|
* SQLite-Datei; darf nicht {@code null} sein
|
||||||
|
* @throws NullPointerException wenn ein Parameter {@code null} ist
|
||||||
|
*/
|
||||||
|
public DefaultCreateNewDatabaseUseCase(DatabaseCreationPort databaseCreationPort,
|
||||||
|
ActiveDatabaseContextPort activeDatabaseContextPort,
|
||||||
|
ActiveDatabasePathSupplier activeDatabasePathSupplier) {
|
||||||
|
this.databaseCreationPort = Objects.requireNonNull(databaseCreationPort,
|
||||||
|
"databaseCreationPort darf nicht null sein");
|
||||||
|
this.activeDatabaseContextPort = Objects.requireNonNull(activeDatabaseContextPort,
|
||||||
|
"activeDatabaseContextPort darf nicht null sein");
|
||||||
|
this.activeDatabasePathSupplier = Objects.requireNonNull(activeDatabasePathSupplier,
|
||||||
|
"activeDatabasePathSupplier darf nicht null sein");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestriert den vollständigen Anlage- und Wechselvorgang.
|
||||||
|
*
|
||||||
|
* @param targetFile der vom Benutzer ausgewählte Zielpfad; darf nicht {@code null} sein
|
||||||
|
* @return strukturiertes Ergebnis mit Erfolg oder klassifiziertem Fehler; nie {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public CreateNewDatabaseResult createNewDatabase(Path targetFile) {
|
||||||
|
Objects.requireNonNull(targetFile, "targetFile darf nicht null sein");
|
||||||
|
Path absoluteTarget = targetFile.toAbsolutePath().normalize();
|
||||||
|
LOG.info("Neue Datenbank anlegen: angeforderter Zielpfad = {}", absoluteTarget);
|
||||||
|
|
||||||
|
// Schritt 1: Pfad-Sicherheitsprüfung
|
||||||
|
Path activeDb = activeDatabasePathSupplier.get();
|
||||||
|
if (activeDb == null) {
|
||||||
|
LOG.error("Aktiver Datenbankpfad ist nicht ermittelbar – Anlage abgebrochen.");
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
||||||
|
"Aktiver Datenbankpfad konnte nicht ermittelt werden.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
Path absoluteActive = activeDb.toAbsolutePath().normalize();
|
||||||
|
boolean sameFile;
|
||||||
|
try {
|
||||||
|
sameFile = isSameFile(absoluteActive, absoluteTarget);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.error("Pfad-Sicherheitsprüfung fehlgeschlagen: {}", e.getMessage(), e);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
||||||
|
"Pfad-Sicherheitsprüfung fehlgeschlagen: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
if (sameFile) {
|
||||||
|
LOG.warn("Anlage abgelehnt: Zielpfad entspricht der aktuell aktiven Datenbank: {}",
|
||||||
|
absoluteTarget);
|
||||||
|
return new CreateNewDatabaseResult.SameAsActiveDatabase(absoluteTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 2: Temp-Datei im Zielverzeichnis vorbereiten
|
||||||
|
Path parent = absoluteTarget.getParent();
|
||||||
|
if (parent == null) {
|
||||||
|
LOG.error("Zielpfad besitzt kein Elternverzeichnis: {}", absoluteTarget);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.PATH_RESOLUTION,
|
||||||
|
"Zielpfad besitzt kein Elternverzeichnis.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!Files.isDirectory(parent)) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.error("Zielverzeichnis konnte nicht angelegt werden: {}", parent, e);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.FILE_CREATION,
|
||||||
|
"Zielverzeichnis konnte nicht angelegt werden: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
Path tempFile = parent.resolve(absoluteTarget.getFileName().toString()
|
||||||
|
+ ".new-" + UUID.randomUUID() + ".tmp");
|
||||||
|
|
||||||
|
// Schritt 3: Adapter führt Anlage + Schema-Migration + Verbindungstest aus
|
||||||
|
DatabaseCreationPort.DatabaseCreationResult creationResult;
|
||||||
|
try {
|
||||||
|
creationResult = databaseCreationPort.createAndInitialize(tempFile);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("Unerwarteter Fehler beim Anlegen der temporären Datenbank: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
deleteTempQuietly(tempFile);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.FILE_CREATION,
|
||||||
|
"Unerwarteter Fehler beim Anlegen der temporären Datenbank: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
if (creationResult instanceof DatabaseCreationPort.DatabaseCreationResult.Failure failure) {
|
||||||
|
CreateNewDatabaseResult.Phase phase = mapPhase(failure.phase());
|
||||||
|
LOG.error("Anlage der neuen Datenbank fehlgeschlagen ({}): {}",
|
||||||
|
failure.phase(), failure.message());
|
||||||
|
deleteTempQuietly(tempFile);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(phase, failure.message(),
|
||||||
|
failure.cause());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 4: atomarer Move auf Zielpfad
|
||||||
|
try {
|
||||||
|
Files.move(tempFile, absoluteTarget,
|
||||||
|
StandardCopyOption.ATOMIC_MOVE,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (AtomicMoveNotSupportedException e) {
|
||||||
|
LOG.error("Atomarer Move nicht unterstützt für Zielpfad {}: {}",
|
||||||
|
absoluteTarget, e.getMessage(), e);
|
||||||
|
deleteTempQuietly(tempFile);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
|
||||||
|
"Atomarer Move (ATOMIC_MOVE + REPLACE_EXISTING) wird vom Dateisystem nicht "
|
||||||
|
+ "unterstützt. Kein nicht-atomarer Fallback. Ziel: "
|
||||||
|
+ absoluteTarget,
|
||||||
|
e);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.error("Atomarer Move fehlgeschlagen: {}", e.getMessage(), e);
|
||||||
|
deleteTempQuietly(tempFile);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.ATOMIC_MOVE,
|
||||||
|
"Verschieben der temporären Datenbank zum Zielpfad fehlgeschlagen: "
|
||||||
|
+ e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 5: Aktive DB-Referenz umstellen
|
||||||
|
try {
|
||||||
|
activeDatabaseContextPort.switchActiveDatabase(absoluteTarget);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.error("Umstellen der aktiven DB-Referenz fehlgeschlagen: {}", e.getMessage(), e);
|
||||||
|
return new CreateNewDatabaseResult.CreationFailed(
|
||||||
|
CreateNewDatabaseResult.Phase.CONTEXT_SWITCH,
|
||||||
|
"Aktive DB-Referenz konnte nicht umgestellt werden: " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.info("Neue Datenbank erfolgreich angelegt und aktiviert: {}", absoluteTarget);
|
||||||
|
return new CreateNewDatabaseResult.Success(absoluteTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergleicht zwei Datenbankpfade mit Berücksichtigung von Symlinks und
|
||||||
|
* (auf Windows) Case-Insensitivität.
|
||||||
|
* <p>
|
||||||
|
* Existieren beide Dateien, wird {@code Files.isSameFile(...)} verwendet.
|
||||||
|
* Existiert eine der beiden Dateien (typischerweise das Ziel) noch nicht, werden
|
||||||
|
* Elternverzeichnisse via {@link Path#toRealPath(java.nio.file.LinkOption...)}
|
||||||
|
* aufgelöst und mit den Dateinamen kombiniert verglichen. Auf Windows erfolgt der
|
||||||
|
* abschließende String-Vergleich case-insensitive.
|
||||||
|
*
|
||||||
|
* @param a Pfad A; darf nicht {@code null} sein
|
||||||
|
* @param b Pfad B; darf nicht {@code null} sein
|
||||||
|
* @return {@code true}, wenn beide Pfade auf dieselbe Datei zeigen
|
||||||
|
* @throws IOException bei Auflösungsfehlern existierender Pfadbestandteile
|
||||||
|
*/
|
||||||
|
static boolean isSameFile(Path a, Path b) throws IOException {
|
||||||
|
Objects.requireNonNull(a, "a darf nicht null sein");
|
||||||
|
Objects.requireNonNull(b, "b darf nicht null sein");
|
||||||
|
if (Files.exists(a) && Files.exists(b)) {
|
||||||
|
return Files.isSameFile(a, b);
|
||||||
|
}
|
||||||
|
Path realA = resolveBest(a);
|
||||||
|
Path realB = resolveBest(b);
|
||||||
|
if (isWindows()) {
|
||||||
|
return realA.toString().equalsIgnoreCase(realB.toString());
|
||||||
|
}
|
||||||
|
return realA.equals(realB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst die existierenden Bestandteile eines Pfades soweit möglich real auf und
|
||||||
|
* normalisiert den Rest. Wird verwendet, wenn die Datei selbst noch nicht existiert.
|
||||||
|
*
|
||||||
|
* @param path der zu normalisierende Pfad
|
||||||
|
* @return ein bestmöglich aufgelöster, normalisierter Pfad
|
||||||
|
* @throws IOException bei {@link Path#toRealPath(java.nio.file.LinkOption...)}-Fehlern
|
||||||
|
*/
|
||||||
|
private static Path resolveBest(Path path) throws IOException {
|
||||||
|
if (Files.exists(path)) {
|
||||||
|
return path.toRealPath();
|
||||||
|
}
|
||||||
|
Path parent = path.toAbsolutePath().normalize().getParent();
|
||||||
|
Path fileName = path.getFileName();
|
||||||
|
if (parent != null && Files.exists(parent)) {
|
||||||
|
return parent.toRealPath().resolve(fileName == null ? "" : fileName.toString());
|
||||||
|
}
|
||||||
|
return path.toAbsolutePath().normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert {@code true}, wenn die laufende JVM auf Windows läuft.
|
||||||
|
*
|
||||||
|
* @return {@code true}, wenn Windows; sonst {@code false}
|
||||||
|
*/
|
||||||
|
private static boolean isWindows() {
|
||||||
|
return OS_NAME.contains("win");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteTempQuietly(Path tempFile) {
|
||||||
|
if (tempFile == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warn("Temporäre Datenbankdatei konnte nicht gelöscht werden: {} – {}",
|
||||||
|
tempFile, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateNewDatabaseResult.Phase mapPhase(
|
||||||
|
DatabaseCreationPort.DatabaseCreationResult.Phase phase) {
|
||||||
|
return switch (phase) {
|
||||||
|
case FILE_CREATION -> CreateNewDatabaseResult.Phase.FILE_CREATION;
|
||||||
|
case SCHEMA_MIGRATION -> CreateNewDatabaseResult.Phase.SCHEMA_MIGRATION;
|
||||||
|
case CONNECTION_TEST -> CreateNewDatabaseResult.Phase.CONNECTION_TEST;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert das gesetzte Override (sofern vorhanden), für Diagnose- und Logging-Zwecke.
|
||||||
|
* Nicht Teil der öffentlichen Use-Case-API.
|
||||||
|
*
|
||||||
|
* @return das Override aus dem {@link ActiveDatabaseContextPort}; nie {@code null}
|
||||||
|
*/
|
||||||
|
Optional<Path> currentOverride() {
|
||||||
|
return activeDatabaseContextPort.activeDatabaseOverride();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user